diff --git a/jpos/src/main/java/org/jpos/core/CryptoEnvironmentProvider.java b/jpos/src/main/java/org/jpos/core/CryptoEnvironmentProvider.java new file mode 100644 index 0000000000..899eb17c07 --- /dev/null +++ b/jpos/src/main/java/org/jpos/core/CryptoEnvironmentProvider.java @@ -0,0 +1,180 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.core; + +import org.jpos.util.Log; +import org.jpos.q2.Q2; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * EnvironmentProvider that encrypts/decrypts values using AES256. + * + *

+ * Format: {@code enc::} + *

+ */ +public class CryptoEnvironmentProvider implements EnvironmentProvider { + private static final Log log = Log.getLog(Q2.LOGGER_NAME, "crypto-env-provider"); + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final String ENC_PREFIX = "enc::"; + private static final int IV_SIZE_BYTES = 12; + private static final int TAG_LENGTH_BITS = 128; + + @Override + public String prefix() { + return ENC_PREFIX; + } + + @Override + public String get(String config) { + try { + String keyName = null; + String encoded; + + int colonIdx = config.indexOf(':'); + if (colonIdx >= 0) { + String possibleKeyName = config.substring(0, colonIdx); + // Valid key names are alphanumeric with optional hyphens/underscores. + // If it doesn't match this pattern, it's probably just part of the Base64 + // payload. + if (possibleKeyName.matches("^[a-zA-Z0-9_\\-]+$")) { + keyName = possibleKeyName; + encoded = config.substring(colonIdx + 1); + } else { + encoded = config; + } + } else { + encoded = config; + } + + byte[] decoded = Base64.getDecoder().decode(encoded); + ByteBuffer buf = ByteBuffer.wrap(decoded); + + // First 12 bytes are the IV/nonce + byte[] iv = new byte[IV_SIZE_BYTES]; + buf.get(iv); + + // Rest is ciphertext with GCM authentication tag + byte[] ciphertext = new byte[buf.remaining()]; + buf.get(ciphertext); + + // Use SystemKeyManager to get the derived key + SecretKey key = SystemKeyManager.getInstance().getKey(keyName); + if (key == null) { + String envVarName = SystemKeyManager.getInstance().getEnvVarName(keyName); + String kName = keyName != null && !keyName.isEmpty() ? keyName : "default"; + log.warn("Key not found in environment for name: " + kName + ". Please set " + envVarName); + throw new RuntimeException( + "Key not found in environment for name: " + kName + ". Please set " + envVarName); + } + // Decrypt with GCM authentication — pass SecretKey directly to support + // non-extractable keys (HSM/PKCS#11) where getEncoded() returns null + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(TAG_LENGTH_BITS, iv); + cipher.init(Cipher.DECRYPT_MODE, key, gcmParameterSpec); + byte[] plaintext = cipher.doFinal(ciphertext); + + return new String(plaintext, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + log.warn("Failed to decrypt value: invalid Base64 encoding or key length — config=" + config, e); + throw new RuntimeException("Failed to decrypt value: invalid Base64 encoding or key length", e); + } catch (Exception e) { + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } + log.warn("Failed to decrypt value: " + e.getMessage() + " — config=" + config, e); + throw new RuntimeException( + "Failed to decrypt value: " + e.getClass().getSimpleName() + " — " + e.getMessage(), e); + } + } + + /** + * Helper method to encrypt a value (for generating encrypted config). + * + * @param value the plaintext value to encrypt + * @return base64-encoded ciphertext with enc:: prefix + */ + public static String encrypt(String value) { + return encrypt(value, null); + } + + /** + * Helper method to encrypt a value with a named key. + * + * @param value the plaintext value to encrypt + * @param keyName the name of the key to use (null for default) + * @return base64-encoded ciphertext with enc::keyname: prefix + */ + public static String encrypt(String value, String keyName) { + try { + SecretKey key = SystemKeyManager.getInstance().getKey(keyName); + if (key == null) { + String envVarName = SystemKeyManager.getInstance().getEnvVarName(keyName); + String kName = keyName != null && !keyName.isEmpty() ? keyName : "default"; + log.warn("Key not found in environment for name: " + kName + ". Please set " + envVarName); + throw new IllegalArgumentException( + "Key not found in environment for name: " + kName + ". Please set " + envVarName); + } + // Encrypt with GCM authentication — pass SecretKey directly to support + // non-extractable keys (HSM/PKCS#11) where getEncoded() returns null + // Generate secure random 12-byte IV + byte[] iv = new byte[IV_SIZE_BYTES]; + new SecureRandom().nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(TAG_LENGTH_BITS, iv); + cipher.init(Cipher.ENCRYPT_MODE, key, gcmParameterSpec); + byte[] ciphertext = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)); + + // Combine IV and ciphertext + ByteBuffer buf = ByteBuffer.allocate(iv.length + ciphertext.length); + buf.put(iv); + buf.put(ciphertext); + + String base64 = Base64.getEncoder().encodeToString(buf.array()); + + // If keyName is provided, include it in the prefix + if (keyName != null && !keyName.isEmpty()) { + return ENC_PREFIX + keyName + ":" + base64; + } else { + return ENC_PREFIX + base64; + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + log.warn("Failed to encrypt value: " + e.getMessage() + " — keyName=" + keyName, e); + throw new RuntimeException( + "Failed to encrypt value: " + e.getClass().getSimpleName() + " — " + e.getMessage(), e); + } + } +} diff --git a/jpos/src/main/java/org/jpos/core/SystemKeyManager.java b/jpos/src/main/java/org/jpos/core/SystemKeyManager.java new file mode 100644 index 0000000000..7dfa74ec8c --- /dev/null +++ b/jpos/src/main/java/org/jpos/core/SystemKeyManager.java @@ -0,0 +1,448 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.core; + +import org.jpos.util.Log; +import org.jpos.q2.Q2; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Manages AES-256 encryption keys loaded from environment variables. + * + *

+ * This class is responsible for loading, generating, and resolving AES-256 + * encryption keys used by jPOS to encrypt sensitive configuration values + * (database passwords, API keys, etc.). + *

+ * + *

Key Loading Strategy

+ *

+ * Keys are loaded strictly from environment variables at lookup time. + * No internal caching is performed — each call to {@link #getKey(String)} + * performs a fresh lookup via the current {@link KeySupplier}. This ensures + * that key changes are always visible and prevents stale keys from persisting + * within a single JVM process. + *

+ *

Key Rotation Limitation

+ *

+ * Because the default supplier reads from {@code System.getenv()}, environment + * variable changes take effect only after a JVM restart. The JVM caches its + * environment variables at startup, so changing an environment variable in a + * running process will not affect key resolution. To rotate keys in production, + * deploy a new process instance with the updated environment variables. + *

+ *

+ * Real-time key rotation is possible only by providing a custom + * {@link KeySupplier} (e.g., one that polls an external secrets manager or + * KMS on each call). The default supplier does not support this. + *

+ * + *

Key Naming Convention

+ *

+ * Each key is identified by a name string. The default key uses the name + * {@code "default"} (or null/empty). Additional keys can be created with + * arbitrary names like {@code "db"}, {@code "api"}, or {@code "cache"}. + *

+ *

+ * The environment variable name is derived from the key name using + * {@link #getEnvVarName(String)}: + *

+ * Non-alphanumeric characters in the key name are normalized to underscores. + *

+ * + *

Key Supplier Pattern

+ *

+ * Key resolution is delegated to a {@link KeySupplier} functional interface, + * enabling clean separation between production and test behavior: + *

+ * + *

+ * The supplier field is {@code volatile} and null-safe: setting it to + * {@code null} restores the default environment-variable behavior. + *

+ * + *

Security Considerations

+ *

+ * Encryption keys MUST be set as environment variables before the JVM starts, + * NOT via {@code System.setProperty()} at runtime. Any code running in the + * same JVM can call {@code System.setProperty()}, which would allow a + * compromised component to inject or replace encryption keys — defeating + * the entire purpose of encrypting sensitive configuration values. + *

+ *

+ * The default supplier accesses only {@code System.getenv()}, which is + * immutable after process start. This provides a clear security boundary: + * keys are set once at startup and cannot be modified from within Java code. + *

+ * + * @see KeySupplier + */ +public class SystemKeyManager { + // Logger for key-related warnings (invalid Base64, wrong key length, etc.) + private static final Log log = Log.getLog(Q2.LOGGER_NAME, "crypto-env-provider"); + + // The default key name — used when no specific key name is provided + private static final String DEFAULT_KEY_NAME = "default"; + + // Base environment variable name for the default encryption key + private static final String DEFAULT_ENV_VAR = "JPOS_ENCRYPTION_KEY"; + + // AES-256 requires exactly 32 bytes (256 bits) + private static final int KEY_SIZE_BITS = 256; + + // Singleton instance — initialized eagerly with the default env-var-based supplier + private static final SystemKeyManager instance = new SystemKeyManager(); + + // The current key supplier. Volatile for thread-safe visibility across threads. + // null means "use default EnvKeySupplier" (lazy fallback in getKey()). + private volatile KeySupplier keySupplier; + + /** + * Private constructor — initializes with the default environment-variable-based supplier. + */ + private SystemKeyManager() { + this.keySupplier = new EnvKeySupplier(); + } + + /** + * Returns the singleton SystemKeyManager instance. + *

+ * This is a true singleton — all callers receive the same instance. + *

+ * + * @return the SystemKeyManager instance + */ + public static SystemKeyManager getInstance() { + return instance; + } + + /** + * Sets a custom KeySupplier for key resolution. + *

+ * This method is package-private and intended solely for use by test code + * within the {@code org.jpos.core} package to inject deterministic keys + * instead of relying on environment variables. It is not part of the + * public production API. + *

+ *

Production Use

+ *

+ * In production, do not call this method. The default supplier loads keys + * from environment variables only — never from {@code System.getProperty()}. + * If real-time key rotation is needed (e.g., polling a KMS), configure it + * through your deployment infrastructure by providing a custom supplier + * at application startup via the appropriate initialization mechanism. + *

+ *

Test Use

+ *

+ * In tests, inject a supplier that returns pre-generated test keys: + *

+ *
{@code
+     * // Generate a key and store it for the test
+     * String base64Key = manager.generateKey("default");
+     * byte[] keyBytes = Base64.getDecoder().decode(base64Key);
+     *
+     * // Inject the supplier — all subsequent getKey() calls will return this key
+     * manager.setKeySupplier(name -> new SecretKeySpec(keyBytes, "AES"));
+     *
+     * // ... run tests ...
+     *
+     * // Restore default behavior (loads from environment variables)
+     * manager.setKeySupplier(null);
+     * }
+ *

+ * The supplier field is {@code volatile} so changes are immediately + * visible across all threads. This is important because encryption/decryption + * operations may occur in different threads than the one that set the supplier. + *

+ * + * @param keySupplier the supplier to use for key resolution; pass {@code null} + * to restore the default environment-variable-based behavior + */ + void setKeySupplier(KeySupplier keySupplier) { + this.keySupplier = keySupplier; + } + + /** + * Gets a SecretKey by name. Returns {@code null} if no key is found. + *

+ * Key resolution is delegated to the current {@link KeySupplier}. + * No caching is performed — each call performs a fresh lookup. + *

+ *

Behavior

+ * + *

Security Note

+ *

+ * In production, the supplier always loads from {@code System.getenv()} only. + * There is NO fallback to {@code System.getProperty()}. This prevents runtime + * key injection via {@code System.setProperty()}, which would be a security vulnerability. + *

+ * + * @param keyName the name of the key to get; {@code null} or empty resolves to the default key + * @return the SecretKey, or {@code null} if not found + */ + public SecretKey getKey(String keyName) { + // Normalize: null/empty key names resolve to the default key + if (keyName == null || keyName.isEmpty()) { + keyName = DEFAULT_KEY_NAME; + } + + // Read volatile field into local variable to avoid stale reads. + // This pattern prevents a race where the supplier is swapped between + // the null check and the actual get() call. + KeySupplier supplier = keySupplier; + if (supplier == null) { + // Restore default behavior: load from environment variables + supplier = new EnvKeySupplier(); + } + + return supplier.get(keyName); + } + + /** + * Gets the default encryption key. Convenience method that delegates to {@link #getKey(String)}. + *

+ * Package-private — intended for internal use within the {@code org.jpos.core} package. + *

+ * + * @return the default SecretKey, or {@code null} if not found + */ + SecretKey getDefaultKey() { + return getKey(DEFAULT_KEY_NAME); + } + + /** + * Gets the Base64-encoded representation of a key by name. + *

+ * This is useful for displaying keys or storing them in configuration files. + * The returned string can be decoded back into a SecretKey using + * {@link #getKey(String)} (via the supplier) or manually via Base64 decoding. + *

+ *

+ * Package-private — intended for internal use within the {@code org.jpos.core} package. + *

+ * + * @param keyName the name of the key + * @return Base64-encoded key string, or {@code null} if the key is not found + */ + String getKeyBase64(String keyName) { + SecretKey key = getKey(keyName); + return key != null ? Base64.getEncoder().encodeToString(key.getEncoded()) : null; + } + + /** + * Generates a new random AES-256 key using OS-provided SecureRandom entropy. + *

+ * This method generates a cryptographically strong random key but does NOT + * set any environment variable or store the key anywhere. The caller is + * responsible for setting the environment variable manually (or injecting + * the key via package-private {@link #setKeySupplier(KeySupplier)} in test code). + *

+ *

Usage

+ *
{@code
+     * String base64Key = manager.generateKey("db");
+     * System.out.println("Set this environment variable:");
+     * System.out.println("  JPOS_ENCRYPTION_KEY_DB=" + base64Key);
+     * }
+ * + * @param keyName the name to give the key (used for naming the environment variable) + * @return the generated key as a Base64-encoded string (32 bytes, URL-safe) + * @throws RuntimeException if the AES key generator fails to initialize + */ + public String generateKey(String keyName) { + try { + // Initialize AES-256 key generator with OS-provided SecureRandom + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(KEY_SIZE_BITS, new SecureRandom()); + SecretKey key = keyGen.generateKey(); + return Base64.getEncoder().encodeToString(key.getEncoded()); + } catch (Exception e) { + throw new RuntimeException("Failed to generate key", e); + } + } + + /** + * Generates a new random AES-256 key for the default key name. + *

+ * Convenience method that delegates to {@link #generateKey(String)} with + * the default key name ({@code "default"}). The corresponding environment + * variable is {@code JPOS_ENCRYPTION_KEY}. + *

+ * + * @return the generated default key as a Base64-encoded string + */ + public String generateDefaultKey() { + return generateKey(DEFAULT_KEY_NAME); + } + + /** + * Gets the environment variable name for a given key name. + *

+ * The naming convention is: + *

+ * Non-alphanumeric characters in the key name are replaced with underscores, + * and the result is uppercased to conform to environment variable naming conventions. + *

+ *

Examples

+ *
{@code
+     * getEnvVarName(null)          -> "JPOS_ENCRYPTION_KEY"
+     * getEnvVarName("")            -> "JPOS_ENCRYPTION_KEY"
+     * getEnvVarName("default")     -> "JPOS_ENCRYPTION_KEY"
+     * getEnvVarName("db")          -> "JPOS_ENCRYPTION_KEY_DB"
+     * getEnvVarName("my-key")      -> "JPOS_ENCRYPTION_KEY_MY_KEY"
+     * getEnvVarName("api_key 123!") -> "JPOS_ENCRYPTION_KEY_API_KEY_123_"
+     * }
+ * + * @param keyName the name of the key; {@code null} or empty resolves to the default + * @return the environment variable name (e.g., {@code "JPOS_ENCRYPTION_KEY_DB"}) + */ + public String getEnvVarName(String keyName) { + if (keyName == null || keyName.isEmpty()) { + keyName = DEFAULT_KEY_NAME; + } + // Build env var name: prefix + "_" + sanitized key name (uppercased, non-alphanumeric replaced with _) + return DEFAULT_ENV_VAR + (DEFAULT_KEY_NAME.equals(keyName) ? "" : "_" + keyName.toUpperCase().replaceAll("[^A-Z0-9]", "_")); + } + + /** + * Functional interface for resolving encryption keys by name. + *

+ * This interface abstracts the source of encryption keys, enabling: + *

+ *

+ *

Implementation Contract

+ *

+ * Implementations must return a valid AES SecretKey for the given name, + * or {@code null} if no key is found. The returned key's encoded bytes + * must be exactly 32 bytes (AES-256). Invalid keys are rejected by callers. + *

+ *

Thread Safety

+ *

+ * Implementations should be thread-safe, as {@link SystemKeyManager#getKey(String)} + * may be called from multiple threads concurrently. The default supplier + * ({@link EnvKeySupplier}) is inherently thread-safe because it only reads + * environment variables (which are immutable after process start). + *

+ */ + @FunctionalInterface + public interface KeySupplier { + /** + * Returns the SecretKey for the given name, or {@code null} if not found. + *

+ * This method is called by {@link SystemKeyManager#getKey(String)} for + * every key lookup. Implementations should perform a fresh lookup each time — + * no caching is expected or required. For real-time key rotation (e.g., polling + * an external secrets manager), implement this interface and set it via + * {@link SystemKeyManager#setKeySupplier(KeySupplier)}. Note that the default + * supplier ({@link EnvKeySupplier}) loads from environment variables, which are + * immutable within a running JVM process. + *

+ * + * @param keyName the name of the key to resolve; never {@code null} (null/empty names are normalized by caller) + * @return the SecretKey for the given name, or {@code null} if not found + */ + SecretKey get(String keyName); + } + + /** + * Default KeySupplier that loads keys from environment variables. + *

+ * This is the production supplier used when no custom supplier is set via + * {@link #setKeySupplier(KeySupplier)}. It reads keys from environment variables + * using the naming convention defined by {@link #getEnvVarName(String)}. + * Environment variables are immutable within a running JVM process; changes + * require a restart to take effect. + *

+ *

Security Boundary

+ *

+ * This supplier accesses ONLY {@code System.getenv()} — never {@code System.getProperty()}. + * Environment variables are set before the JVM starts and cannot be modified from within + * Java code, providing a clear security boundary against runtime key injection. + *

+ *

Error Handling

+ *

+ * If the Base64 decoding fails or the key length is incorrect, a warning is logged + * via {@link Log#warn(Object)} and {@code null} is returned (same as "key not found"). + * This prevents exceptions from crashing the application while still alerting operators. + *

+ */ + private class EnvKeySupplier implements KeySupplier { + @Override + public SecretKey get(String keyName) { + // Look up the environment variable name for this key + String envVarName = SystemKeyManager.this.getEnvVarName(keyName); + + // Read from environment variable — this is IMMUTABLE after process start. + // We intentionally do NOT fall back to System.getProperty() here. + // A System.getProperty fallback would be a security vulnerability because + // any code in the JVM could call System.setProperty() to inject/replace keys. + String envValue = System.getenv(envVarName); + + if (envValue != null && !envValue.trim().isEmpty()) { + try { + byte[] keyBytes = Base64.getDecoder().decode(envValue.trim()); + + // Validate key length: AES-256 requires exactly 32 bytes + if (keyBytes.length == KEY_SIZE_BITS / 8) { + return new SecretKeySpec(keyBytes, "AES"); + } else { + log.warn("Invalid key length in " + envVarName + ": expected " + (KEY_SIZE_BITS / 8) + " bytes, got " + keyBytes.length); + } + } catch (IllegalArgumentException e) { + // Invalid Base64 encoding — log warning and return null (same as "not found") + log.warn("Invalid Base64 in " + envVarName + ": " + e.getMessage()); + } + } + + // Key not found, invalid, or malformed — return null + return null; + } + } +} diff --git a/jpos/src/main/java/org/jpos/q2/cli/CRYPTO.java b/jpos/src/main/java/org/jpos/q2/cli/CRYPTO.java new file mode 100644 index 0000000000..25ac356c0c --- /dev/null +++ b/jpos/src/main/java/org/jpos/q2/cli/CRYPTO.java @@ -0,0 +1,78 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.q2.cli; + +import org.jpos.core.CryptoEnvironmentProvider; +import org.jpos.core.SystemKeyManager; +import org.jpos.q2.CLICommand; +import org.jpos.q2.CLIContext; + +/** + * Encrypt a secret using AES-256-GCM + *

+ * Usage: crypto "secret" + * Output: enc::base64-encoded-ciphertext + */ +public class CRYPTO implements CLICommand { + + @Override + public void exec(CLIContext cli, String[] args) throws Exception { + if (args.length < 2 || args.length > 3) { + usage(cli); + return; + } + + String command = args[1]; + + if ("generate".equals(command)) { + handleGenerate(cli, args); + } else { + handleEncrypt(cli, command, args); + } + } + + private void handleGenerate(CLIContext cli, String[] args) { + String keyName = args.length > 2 ? args[2] : null; + SystemKeyManager manager = SystemKeyManager.getInstance(); + + String keyBase64 = manager.generateKey(keyName); + String envVarName = manager.getEnvVarName(keyName); + + cli.println("=== Key Generated ==="); + cli.println("Key Name: " + (keyName == null ? "default" : keyName)); + cli.println("Environment Variable: " + envVarName); + cli.println("Key (Base64): " + keyBase64); + cli.println("====================="); + } + + private void handleEncrypt(CLIContext cli, String command, String[] args) { + String keyName = args.length > 2 ? args[2] : null; + + String encrypted = CryptoEnvironmentProvider.encrypt(command, keyName); + cli.println(encrypted); + } + + public void usage(CLIContext cli) { + cli.println("Usage: crypto \"secret\" [keyName]"); + cli.println("Encrypts a secret using AES-256-GCM authenticated encryption."); + cli.println("Output format: enc::keyname:base64-encoded-ciphertext"); + cli.println("If keyName is not provided, uses the default key."); + cli.println("The encrypted value can be used in db.properties with the enc:: prefix."); + } +} diff --git a/jpos/src/main/resources/META-INF/services/org.jpos.core.EnvironmentProvider b/jpos/src/main/resources/META-INF/services/org.jpos.core.EnvironmentProvider index 56e27cf1ae..b2b72dda36 100644 --- a/jpos/src/main/resources/META-INF/services/org.jpos.core.EnvironmentProvider +++ b/jpos/src/main/resources/META-INF/services/org.jpos.core.EnvironmentProvider @@ -1,2 +1,3 @@ org.jpos.core.FileEnvironmentProvider org.jpos.core.ObfEnvironmentProvider +org.jpos.core.CryptoEnvironmentProvider diff --git a/jpos/src/test/java/org/jpos/core/CRYPTOTest.java b/jpos/src/test/java/org/jpos/core/CRYPTOTest.java new file mode 100644 index 0000000000..3531b04b54 --- /dev/null +++ b/jpos/src/test/java/org/jpos/core/CRYPTOTest.java @@ -0,0 +1,281 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.core; + +import org.jpos.q2.CLI; +import org.jpos.q2.CLIContext; +import org.jpos.q2.cli.CRYPTO; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the {@link CRYPTO} CLI command. + *

+ * The CRYPTO command allows users to encrypt secrets via the jPOS CLI: + *

{@code
+ * crypto "my-secret-password"          # encrypt with default key
+ * crypto "my-secret-password" db       # encrypt with "db" named key
+ * }
+ *

+ *

Test Key Injection Pattern

+ *

+ * These tests use the same {@link SystemKeyManager.KeySupplier} injection pattern as other + * SystemKeyManager tests. The supplier is set up in {@link #setup()} with a default key, + * and individual test methods add additional named keys as needed via {@code testKeys.put()}. + *

+ *

+ * Cleanup happens in {@link #cleanup()}, which resets the supplier to null (restoring + * environment-variable behavior) and clears the local test key map. + *

+ */ +public class CRYPTOTest { + + // Local storage for test keys: maps key names to their raw byte values. + // Populated in setup() with the default key, and extended by individual test methods. + private final Map testKeys = new HashMap<>(); + + /** + * Sets up a test supplier with a randomly generated default key before each test. + * This ensures every test starts with a valid encryption key without relying on + * environment variables. + */ + @BeforeEach + void setup() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateKey("default"); + testKeys.put("default", Base64.getDecoder().decode(base64Key)); + manager.setKeySupplier(this::getTestKey); + } + + /** + * Cleans up the supplier after each test to ensure test isolation. + */ + @AfterEach + void cleanup() { + SystemKeyManager.getInstance().setKeySupplier(null); + testKeys.clear(); + } + + /** + * Returns a SecretKey for the given key name by looking it up in the testKeys map. + * This method is used as a method reference for {@link SystemKeyManager#setKeySupplier(KeySupplier)}. + * + * @param name the key name to look up + * @return the SecretKey, or null if not found + */ + private SecretKey getTestKey(String name) { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + } + + /** + * Verifies that the CRYPTO command encrypts a secret and produces output + * starting with {@code enc::}, which is the prefix used by {@link CryptoEnvironmentProvider}. + *

+ * The test then decrypts the output to verify the round-trip produces the original input. + *

+ */ + @Test + public void testCryptoCommandEncryptsDirectly() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + + CLI cliObj = new CLI(null, in, out, null, false, false); + + CLIContext cli = CLIContext.builder() + .cli(cliObj) + .out(out) + .build(); + + CRYPTO cmd = new CRYPTO(); + + // args[0] = "crypto", args[1] = "my-secret-password" + cmd.exec(cli, new String[]{"crypto", "my-secret-password"}); + + String output = out.toString().trim(); + assertNotNull(output); + assertTrue(output.startsWith("enc::"), "Output should start with 'enc::'"); + + // Decrypt the output to verify the round-trip produces the original input + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + String decrypted = provider.get(output.substring(5)); + assertEquals("my-secret-password", decrypted, "Decrypted text should match the CLI input"); + } + + /** + * Verifies that the CRYPTO command with a named key ("db") produces output + * starting with {@code enc::db:}, which includes the key name in the ciphertext. + */ + @Test + public void testCryptoCommandWithKeyName() throws Exception { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateKey("db"); + testKeys.put("db", Base64.getDecoder().decode(base64Key)); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + + CLI cliObj = new CLI(null, in, out, null, false, false); + + CLIContext cli = CLIContext.builder() + .cli(cliObj) + .out(out) + .build(); + + CRYPTO cmd = new CRYPTO(); + + // args[0] = "crypto", args[1] = "my-secret-password", args[2] = "db" + cmd.exec(cli, new String[]{"crypto", "my-secret-password", "db"}); + + String output = out.toString().trim(); + assertNotNull(output); + assertTrue(output.startsWith("enc::db:"), "Output should start with 'enc::db:'"); + + // Decrypt to verify the round-trip produces the original input + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + String decrypted = provider.get(output.substring(5)); + assertEquals("my-secret-password", decrypted, "Decrypted text should match the CLI input"); + } + + /** + * Verifies that the CRYPTO command rejects too many arguments by printing usage instructions. + */ + @Test + public void testCryptoCommandTooManyArguments() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + + CLI cliObj = new CLI(null, in, out, null, false, false); + + CLIContext cli = CLIContext.builder() + .cli(cliObj) + .out(out) + .build(); + + CRYPTO cmd = new CRYPTO(); + + // passing 4 arguments: args[0]="crypto", args[1]="secret", args[2]="db", args[3]="extra" + cmd.exec(cli, new String[]{"crypto", "my-secret-password", "db", "extra"}); + + String output = out.toString().trim(); + assertTrue(output.contains("Usage: crypto"), "Output should contain usage instructions when given too many arguments"); + } + + /** + * Verifies that {@link CryptoEnvironmentProvider#encrypt(String)} produces valid encrypted output + * with the correct prefix and a ciphertext long enough to indicate real encryption. + */ + @Test + public void testCryptoDirectly() throws Exception { + String input = "my-password"; + String encrypted = CryptoEnvironmentProvider.encrypt(input); + + assertNotNull(encrypted); + assertTrue(encrypted.startsWith("enc::"), "Output should start with 'enc::'"); + + String encryptedPart = encrypted.substring(5); + + byte[] decoded = java.util.Base64.getDecoder().decode(encryptedPart); + assertNotNull(decoded); + assertTrue(decoded.length > 12, "Ciphertext should be longer than just the plaintext"); + } + + /** + * Verifies that encrypting the same value twice produces different ciphertexts. + * This confirms that AES-GCM is using a random IV (initialization vector) each time, + * which is essential for semantic security — identical plaintexts should not produce + * identical ciphertexts. + */ + @Test + public void testDifferentEncryptions() throws Exception { + String input = "same-password"; + String encrypted1 = CryptoEnvironmentProvider.encrypt(input); + String encrypted2 = CryptoEnvironmentProvider.encrypt(input); + + assertNotEquals(encrypted1, encrypted2); + } + + /** + * Verifies that encrypting with a named key ("db") produces output starting with + * {@code enc::db:}, confirming the key name is embedded in the ciphertext prefix. + */ + @Test + public void testEncryptWithKeyName() throws Exception { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateKey("db"); + testKeys.put("db", Base64.getDecoder().decode(base64Key)); + + String input = "my-password"; + String encrypted = CryptoEnvironmentProvider.encrypt(input, "db"); + + assertNotNull(encrypted); + assertTrue(encrypted.startsWith("enc::db:"), "Output should start with 'enc::db:'"); + } + + /** + * Verifies that encrypting with multiple named keys ("db", "api", "cache") produces + * distinct ciphertexts for each key. This confirms that different keys produce + * different encrypted outputs even when the plaintext is identical. + */ + @Test + public void testEncryptWithMultipleKeyNames() throws Exception { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Db = manager.generateKey("db"); + String base64Api = manager.generateKey("api"); + String base64Cache = manager.generateKey("cache"); + testKeys.put("db", Base64.getDecoder().decode(base64Db)); + testKeys.put("api", Base64.getDecoder().decode(base64Api)); + testKeys.put("cache", Base64.getDecoder().decode(base64Cache)); + + String input = "test-password"; + String encryptedDb = CryptoEnvironmentProvider.encrypt(input, "db"); + String encryptedApi = CryptoEnvironmentProvider.encrypt(input, "api"); + String encryptedCache = CryptoEnvironmentProvider.encrypt(input, "cache"); + + assertNotNull(encryptedDb); + assertNotNull(encryptedApi); + assertNotNull(encryptedCache); + + assertTrue(encryptedDb.startsWith("enc::db:")); + assertTrue(encryptedApi.startsWith("enc::api:")); + assertTrue(encryptedCache.startsWith("enc::cache:")); + + // Extract the ciphertext portion (after the key name prefix) + String encryptedPartDb = encryptedDb.substring(7); + String encryptedPartApi = encryptedApi.substring(8); + String encryptedPartCache = encryptedCache.substring(9); + + assertNotEquals(encryptedPartDb, encryptedPartApi, "Base64 parts should be different for different keys"); + assertNotEquals(encryptedPartApi, encryptedPartCache, "Base64 parts should be different for different keys"); + assertNotEquals(encryptedPartDb, encryptedPartCache, "Base64 parts should be different for different keys"); + } + +} diff --git a/jpos/src/test/java/org/jpos/core/CryptoEnvironmentProviderTest.java b/jpos/src/test/java/org/jpos/core/CryptoEnvironmentProviderTest.java new file mode 100644 index 0000000000..44526c27b9 --- /dev/null +++ b/jpos/src/test/java/org/jpos/core/CryptoEnvironmentProviderTest.java @@ -0,0 +1,385 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.core; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link CryptoEnvironmentProvider}. + *

+ * These tests verify the encrypt/decrypt round-trip behavior of AES-256-GCM encryption, + * including various input types (URLs, passwords, Unicode, special characters, long strings). + *

+ *

Test Key Injection Pattern

+ *

+ * Like all SystemKeyManager tests, this class uses the {@link SystemKeyManager.KeySupplier} + * injection pattern. A default key is generated in {@link #setup()} and injected via a supplier + * that looks up keys from the local {@code testKeys} map. Cleanup in {@link #cleanup()} resets + * the supplier to null (restoring environment-variable behavior). + *

+ */ +public class CryptoEnvironmentProviderTest { + + // Local storage for test keys: maps key names to their raw byte values. + private final Map testKeys = new HashMap<>(); + + /** + * Sets up a test supplier with a randomly generated default key before each test. + */ + @BeforeEach + void setup() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateKey("default"); + testKeys.put("default", Base64.getDecoder().decode(base64Key)); + manager.setKeySupplier(this::getTestKey); + } + + /** + * Cleans up the supplier after each test to ensure test isolation. + */ + @AfterEach + void cleanup() { + SystemKeyManager.getInstance().setKeySupplier(null); + testKeys.clear(); + } + + /** + * Returns a SecretKey for the given key name by looking it up in the testKeys map. + * Used as a method reference for {@link SystemKeyManager#setKeySupplier(KeySupplier)}. + */ + private SecretKey getTestKey(String name) { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + } + + /** + * Verifies that encrypting a URL and decrypting it produces the original value. + */ + @Test + public void testEncryptDecryptRoundTrip() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String original = "localhost:3306/client?useSSL=false"; + String encrypted = CryptoEnvironmentProvider.encrypt(original); + + assertNotNull(encrypted); + assertTrue(encrypted.startsWith("enc::")); + + String decrypted = provider.get(encrypted.substring(5)); + assertEquals(original, decrypted); + } + + /** + * Verifies decryption of a database password. + */ + @Test + public void testDecryptDatabasePassword() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String password = "secretpassword"; + String encrypted = CryptoEnvironmentProvider.encrypt(password); + + String decrypted = provider.get(encrypted.substring(5)); + assertEquals(password, decrypted); + } + + /** + * Verifies decryption of a database username. + */ + @Test + public void testDecryptDatabaseUsername() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String username = "client"; + String encrypted = CryptoEnvironmentProvider.encrypt(username); + + String decrypted = provider.get(encrypted.substring(5)); + assertEquals(username, decrypted); + } + + /** + * Verifies that encrypting the same value twice produces different ciphertexts. + * This confirms AES-GCM is using a random IV each time (semantic security). + */ + @Test + public void testDifferentEncryptionsProduceDifferentOutput() { + String value = "test-value"; + String encrypted1 = CryptoEnvironmentProvider.encrypt(value); + String encrypted2 = CryptoEnvironmentProvider.encrypt(value); + + assertNotEquals(encrypted1, encrypted2, "Each encryption should use a different IV"); + } + + /** + * Verifies that encrypting and decrypting an empty string works correctly. + */ + @Test + public void testDecryptEmptyString() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String encrypted = CryptoEnvironmentProvider.encrypt(""); + String decrypted = provider.get(encrypted.substring(5)); + + assertEquals("", decrypted); + } + + /** + * Verifies that encrypting and decrypting a string with special characters works correctly. + */ + @Test + public void testDecryptSpecialCharacters() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String special = "p@ssw0rd!#$%^&*()"; + String encrypted = CryptoEnvironmentProvider.encrypt(special); + String decrypted = provider.get(encrypted.substring(5)); + + assertEquals(special, decrypted); + } + + /** + * Verifies that encrypting and decrypting Unicode characters (Chinese) works correctly. + */ + @Test + public void testDecryptUnicodeCharacters() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String unicode = "密码 123 密码"; + String encrypted = CryptoEnvironmentProvider.encrypt(unicode); + String decrypted = provider.get(encrypted.substring(5)); + + assertEquals(unicode, decrypted); + } + + /** + * Verifies that encrypting and decrypting a long string (1000 iterations of "test-") works correctly. + */ + @Test + public void testDecryptLongString() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + sb.append("test-"); + } + String longValue = sb.toString(); + + String encrypted = CryptoEnvironmentProvider.encrypt(longValue); + String decrypted = provider.get(encrypted.substring(5)); + + assertEquals(longValue, decrypted); + } + + /** + * Verifies that the prefix returned by {@link CryptoEnvironmentProvider#prefix()} is "enc::". + */ + @Test + public void testPrefixReturnsCrypto() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + assertEquals("enc::", provider.prefix()); + } + + /** + * Verifies that passing invalid Base64 to {@link CryptoEnvironmentProvider#get(String)} throws a RuntimeException. + */ + @Test + public void testInvalidBase64ThrowsException() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + assertRuntimeException(() -> provider.get("invalid-base64!!!")); + } + + /** + * Verifies that tampering with the ciphertext (changing one character) causes decryption to fail. + * AES-GCM provides authenticated encryption — any modification to the ciphertext is detected. + */ + @Test + public void testTamperedCiphertextThrowsException() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String original = "test-value"; + String encrypted = CryptoEnvironmentProvider.encrypt(original); + + // Tamper with the ciphertext by replacing the first character after "enc::" + String tampered = "enc::" + encrypted.replaceFirst(".", "X"); + + assertRuntimeException(() -> provider.get(tampered.substring(5))); + } + + /** + * Verifies that passing {@code null} to {@link CryptoEnvironmentProvider#get(String)} throws a RuntimeException. + */ + @Test + public void testNullInputThrowsException() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + assertRuntimeException(() -> provider.get(null)); + } + + /** + * Verifies decrypting a full MySQL JDBC URL. + */ + @Test + public void testDecryptMySQLFullUrl() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String url = "jdbc:mysql://localhost:3306/client?useSSL=false"; + String encrypted = CryptoEnvironmentProvider.encrypt(url); + String decrypted = provider.get(encrypted.substring(5)); + + assertEquals(url, decrypted); + } + + /** + * Verifies decrypting a PostgreSQL JDBC URL. + */ + @Test + public void testDecryptPostgreSQLUrl() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String url = "jdbc:postgresql://db.example.com:5432/mydb"; + String encrypted = CryptoEnvironmentProvider.encrypt(url); + String decrypted = provider.get(encrypted.substring(5)); + + assertEquals(url, decrypted); + } + + /** + * Verifies decrypting a query string with multiple parameters. + */ + @Test + public void testDecryptWithQueryParameters() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String value = "user=admin&password=secret&database=mydb"; + String encrypted = CryptoEnvironmentProvider.encrypt(value); + String decrypted = provider.get(encrypted.substring(5)); + + assertEquals(value, decrypted); + } + + /** + * Verifies decrypting a URL with special characters in the credentials. + */ + @Test + public void testDecryptSpecialUrlCharacters() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + String value = "user:pass@host:3306/db?param=value&another=123"; + String encrypted = CryptoEnvironmentProvider.encrypt(value); + String decrypted = provider.get(encrypted.substring(5)); + + assertEquals(value, decrypted); + } + + /** + * Verifies encrypting and decrypting with a named key ("db"). + */ + @Test + public void testEncryptDecryptWithKeyName() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateKey("db"); + testKeys.put("db", Base64.getDecoder().decode(base64Key)); + + String original = "my-database-password"; + String encrypted = CryptoEnvironmentProvider.encrypt(original, "db"); + + assertNotNull(encrypted); + assertTrue(encrypted.startsWith("enc::db:")); + + String decrypted = provider.get(encrypted.substring(5)); + assertEquals(original, decrypted); + } + + /** + * Verifies encrypting with multiple named keys ("db", "api") produces distinct ciphertexts + * that can each be decrypted back to the original value using the same key. + */ + @Test + public void testEncryptDecryptWithMultipleKeyNames() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Db = manager.generateKey("db"); + String base64Api = manager.generateKey("api"); + testKeys.put("db", Base64.getDecoder().decode(base64Db)); + testKeys.put("api", Base64.getDecoder().decode(base64Api)); + + String original = "test-value"; + String encryptedDb = CryptoEnvironmentProvider.encrypt(original, "db"); + String encryptedApi = CryptoEnvironmentProvider.encrypt(original, "api"); + + assertNotNull(encryptedDb); + assertNotNull(encryptedApi); + + assertTrue(encryptedDb.startsWith("enc::db:")); + assertTrue(encryptedApi.startsWith("enc::api:")); + + String decryptedDb = provider.get(encryptedDb.substring(5)); + String decryptedApi = provider.get(encryptedApi.substring(5)); + + assertEquals(original, decryptedDb); + assertEquals(original, decryptedApi); + + assertNotEquals(encryptedDb, encryptedApi, "Encrypted values should differ for different keys"); + } + + /** + * Verifies that the provider handles arbitrary strings with colons robustly. + * The test creates an artificial scenario where the input resembles a key-value pair + * but contains invalid Base64 content, expecting a RuntimeException. + */ + @Test + public void testBase64WithColonsIsProperlyProcessed() { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + + // Let's create an artificial scenario where the base64 happens to start with something resembling a key + // But with an invalid key name pattern like "+abc:" + // Note: the test just verifies the method handles arbitrary strings robustly. + assertRuntimeException(() -> provider.get("+abc:invalidbase64content")); + } + + /** + * Helper method to assert that a Runnable throws a RuntimeException. + */ + private void assertRuntimeException(Runnable runnable) { + try { + runnable.run(); + fail("Expected RuntimeException to be thrown"); + } catch (RuntimeException e) { + // Expected + assertNotNull(e.getMessage()); + } + } + +} diff --git a/jpos/src/test/java/org/jpos/core/DeployWithEncryptedPropsTest.java b/jpos/src/test/java/org/jpos/core/DeployWithEncryptedPropsTest.java new file mode 100644 index 0000000000..9245802494 --- /dev/null +++ b/jpos/src/test/java/org/jpos/core/DeployWithEncryptedPropsTest.java @@ -0,0 +1,144 @@ +package org.jpos.core; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Path; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for deploying jPOS configuration files with encrypted properties. + *

+ * This test simulates the real-world scenario where a jPOS Q2 instance loads + * configuration from {@code .cfg} files in a {@code cfg/} directory, and those + * files contain encrypted values (e.g., database passwords prefixed with "enc::"). + *

+ *

How It Works

+ *

+ * The test: + *

    + *
  1. Creates a temporary directory with a {@code cfg/} subdirectory
  2. + *
  3. Writes a {@code db.cfg} file containing an encrypted password
  4. + *
  5. Sets system properties to point jPOS at the temporary directory: {@code jpos.envdir} and {@code jpos.env}
  6. + *
  7. reloads the Environment so it picks up the configuration file
  8. + *
  9. Resolves the password via {@link Environment#get(String)} — this should automatically decrypt it
  10. + *
+ *

+ *

Test Key Injection

+ *

+ * The encryption key for the "test" key name is injected via + * {@link SystemKeyManager#setKeySupplier(KeySupplier)} in {@link #setup()}. + * Cleanup in {@link #cleanup()} restores original system properties and resets the supplier. + *

+ */ +public class DeployWithEncryptedPropsTest { + + @TempDir + Path tempDir; + + // Local storage for test keys: maps key names to their raw byte values. + private final Map testKeys = new HashMap<>(); + + // Original values of system properties that we save and restore after the test. + // This prevents tests from polluting the global system property state. + private String originalEnvDir; + private String originalEnv; + + /** + * Sets up a test supplier with a randomly generated key for the "test" key name, + * and saves the original values of jpos.system properties to restore later. + */ + @BeforeEach + void setup() throws Exception { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateKey("test"); + testKeys.put("test", Base64.getDecoder().decode(base64Key)); + + // Inject the test key supplier — all subsequent getKey() calls will return our test key for "test" + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + + // Save original values so we can restore them after the test + originalEnvDir = System.getProperty("jpos.envdir"); + originalEnv = System.getProperty("jpos.env"); + } + + /** + * Cleans up: resets the key supplier, restores original system properties, + * and reloads the Environment to clear any cached state from this test. + */ + @AfterEach + void cleanup() throws Exception { + SystemKeyManager.getInstance().setKeySupplier(null); + testKeys.clear(); + + // Restore original environment directory setting + if (originalEnvDir != null) { + System.setProperty("jpos.envdir", originalEnvDir); + } else { + System.clearProperty("jpos.envdir"); + } + + // Restore original environment profile setting + if (originalEnv != null) { + System.setProperty("jpos.env", originalEnv); + } else { + System.clearProperty("jpos.env"); + } + + // Reload the Environment to clear any cached state from this test + Environment.reload(); + } + + /** + * Verifies that jPOS correctly loads and decrypts properties from a {@code .cfg} file. + *

+ * This test simulates the real-world scenario where: + *

    + *
  • A {@code cfg/db.cfg} file contains encrypted database credentials
  • + *
  • jPOS loads this file when {@code jpos.envdir} and {@code jpos.env} are set
  • + *
  • The Environment automatically decrypts values prefixed with "enc::"
  • + *
+ *

+ */ + @Test + public void testDeployWithEncryptedProperties() throws Exception { + CryptoEnvironmentProvider provider = new CryptoEnvironmentProvider(); + + // Encrypt a password using the "test" key name + String clearPassword = "my-encrypted-password"; + String encryptedPassword = provider.encrypt(clearPassword, "test"); + + // Create a temporary cfg directory with a db.cfg file containing the encrypted password + File cfgDir = tempDir.resolve("cfg").toFile(); + cfgDir.mkdirs(); + + // Write .cfg instead of .properties so Environment will load it directly + File dbProps = new File(cfgDir, "db.cfg"); + try (FileWriter writer = new FileWriter(dbProps)) { + writer.write("hibernate.connection.password=" + encryptedPassword + "\n"); + } + + // Instruct Environment to load from our temporary cfg dir and "db" profile + System.setProperty("jpos.envdir", cfgDir.getAbsolutePath()); + System.setProperty("jpos.env", "db"); + Environment.reload(); + + // Evaluate the property through the Environment resolution exactly as Q2 would. + // This should automatically decrypt the password (the value starts with "enc::test:") + String decryptedPassword = Environment.get("${hibernate.connection.password}"); + assertEquals(clearPassword, decryptedPassword, "Environment should automatically decrypt the password"); + } +} diff --git a/jpos/src/test/java/org/jpos/core/EnvironmentResolveTest.java b/jpos/src/test/java/org/jpos/core/EnvironmentResolveTest.java new file mode 100644 index 0000000000..b2ea58d24c --- /dev/null +++ b/jpos/src/test/java/org/jpos/core/EnvironmentResolveTest.java @@ -0,0 +1,70 @@ +package org.jpos.core; + +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for environment-based property resolution with encrypted values. + *

+ * This test verifies that the {@link Environment} class correctly resolves + * encrypted properties (prefixed with "enc::") when they are set as system properties. + * The encryption/decryption uses {@link CryptoEnvironmentProvider}, which in turn + * relies on {@link SystemKeyManager} to load the encryption key. + *

+ *

Test Key Injection

+ *

+ * Since tests cannot rely on environment variables, we inject a test key via + * {@link SystemKeyManager#setKeySupplier(KeySupplier)}. The supplier looks up keys + * from the local {@code testKeys} map. + *

+ */ +public class EnvironmentResolveTest { + + // Local storage for test keys: maps key names to their raw byte values. + private final Map testKeys = new HashMap<>(); + + /** + * Verifies that the Environment class correctly resolves an encrypted property. + *

+ * The flow is: + *

    + *
  1. Generate a test key and inject it via {@code setKeySupplier}
  2. + *
  3. Encrypt a secret value using {@link CryptoEnvironmentProvider#encrypt(String)}
  4. + *
  5. Set the encrypted value as a system property
  6. + *
  7. Resolve the property via {@link Environment#get(String)} with a placeholder reference
  8. + *
  9. Verify the decrypted value matches the original secret
  10. + *
+ *

+ */ + @Test + public void testResolveEnc() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateKey("default"); + testKeys.put("default", Base64.getDecoder().decode(base64Key)); + + // Inject the test key supplier — all subsequent getKey() calls will return our test key + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + + // Encrypt a secret and set it as a system property + System.setProperty("my.test.prop", CryptoEnvironmentProvider.encrypt("secret")); + + // Resolve the property through the Environment — this should automatically decrypt it + String res = Environment.get("${my.test.prop}"); + assertEquals("secret", res); + + // Clean up: remove the system property and reset the supplier + System.clearProperty("my.test.prop"); + manager.setKeySupplier(null); + testKeys.clear(); + } +} diff --git a/jpos/src/test/java/org/jpos/core/SimpleConfigurationWithCryptoTest.java b/jpos/src/test/java/org/jpos/core/SimpleConfigurationWithCryptoTest.java new file mode 100644 index 0000000000..3ec74df05b --- /dev/null +++ b/jpos/src/test/java/org/jpos/core/SimpleConfigurationWithCryptoTest.java @@ -0,0 +1,102 @@ +package org.jpos.core; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link SimpleConfiguration} with encrypted property values. + *

+ * This test verifies that {@code SimpleConfiguration} correctly resolves encrypted + * properties when they reference other system properties containing encrypted values. + *

+ *

How It Works

+ *

+ * The test creates a scenario where: + *

    + *
  1. A secret password is encrypted using {@link CryptoEnvironmentProvider#encrypt(String)}
  2. + *
  3. The encrypted value is set as a system property ({@code db.password.encrypted})
  4. + *
  5. A {@code SimpleConfiguration} maps {@code db.password} to a placeholder reference: {@code ${db.password.encrypted}}
  6. + *
  7. When {@code cfg.get("db.password")} is called, the configuration resolves the placeholder, + * reads the system property, and decrypts the value
  8. + *
+ *

+ *

Test Key Injection

+ *

+ * The encryption key is injected via {@link SystemKeyManager#setKeySupplier(KeySupplier)} in + * {@link #setup()}, and cleaned up in {@link #cleanup()}. + *

+ */ +public class SimpleConfigurationWithCryptoTest { + + // Local storage for test keys: maps key names to their raw byte values. + private final Map testKeys = new HashMap<>(); + + /** + * Sets up a test supplier with a randomly generated default key before each test. + */ + @BeforeEach + void setup() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateKey("default"); + testKeys.put("default", Base64.getDecoder().decode(base64Key)); + + // Inject the test key supplier — all subsequent getKey() calls will return our test key + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + } + + /** + * Cleans up the supplier after each test to ensure test isolation. + */ + @AfterEach + void cleanup() { + SystemKeyManager.getInstance().setKeySupplier(null); + testKeys.clear(); + } + + /** + * Verifies that SimpleConfiguration correctly resolves an encrypted property reference. + *

+ * The flow: + *

    + *
  1. Encrypt a password using CryptoEnvironmentProvider
  2. + *
  3. Set the encrypted value as a system property
  4. + *
  5. Create a SimpleConfiguration that maps db.password to ${db.password.encrypted}
  6. + *
  7. Call cfg.get("db.password") — this should resolve the placeholder and decrypt
  8. + *
  9. Verify the result matches the original plaintext password
  10. + *
+ *

+ */ + @Test + public void testSimpleConfigurationGet() throws Exception { + String password = "secretpassword"; + + // Encrypt the password — produces a string like "enc::default:base64-ciphertext" + String encryptedPassword = CryptoEnvironmentProvider.encrypt(password); + + Properties props = new Properties(); + // Environment evaluates ${...} properties by resolving their references + // (from env/sys/cfg) and applying EnvironmentProviders if there's a matching prefix (like enc::) + System.setProperty("db.password.encrypted", encryptedPassword); + props.setProperty("db.password", "${db.password.encrypted}"); + + SimpleConfiguration cfg = new SimpleConfiguration(props); + + // This should resolve the placeholder, read the system property, and decrypt + assertEquals(password, cfg.get("db.password")); + + System.clearProperty("db.password.encrypted"); + } +} diff --git a/jpos/src/test/java/org/jpos/core/SystemKeyManagerTest.java b/jpos/src/test/java/org/jpos/core/SystemKeyManagerTest.java new file mode 100644 index 0000000000..0f3e44c432 --- /dev/null +++ b/jpos/src/test/java/org/jpos/core/SystemKeyManagerTest.java @@ -0,0 +1,260 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.core; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link SystemKeyManager}. + *

+ * These tests verify key generation, resolution, and the environment variable naming convention. + *

+ *

Test Key Injection Pattern

+ *

+ * Since {@code SystemKeyManager} is a singleton with no internal test caches (production code + * must never leak test infrastructure), tests inject keys via the {@link SystemKeyManager.KeySupplier} + * interface. The pattern used in every test method is: + *

+ *
    + *
  1. Generate a random key using {@code manager.generateKey(name)}
  2. + *
  3. Decode the Base64 string to raw bytes
  4. + *
  5. Store the bytes in a local {@code Map testKeys}
  6. + *
  7. Inject a supplier that looks up keys from this map: {@code manager.setKeySupplier(name -> ...)}
  8. + *
  9. Run assertions against {@code manager.getKey(name)}
  10. + *
  11. Clean up in {@link #cleanup()}: reset supplier to null and clear the map
  12. + *
+ *

+ * The {@code cleanup()} method runs after every test via {@literal @}AfterEach, ensuring test isolation. + * Setting the supplier back to {@code null} restores default environment-variable behavior for any + * subsequent tests that might depend on it. + *

+ */ +public class SystemKeyManagerTest { + + // The key name used across all tests — "default" is the built-in default + private static final String DEFAULT = "default"; + + // Local storage for test keys: maps key names to their raw byte values. + // This map is populated in each test method and cleared after every test. + // It exists ONLY in test code — never in production. + private final Map testKeys = new HashMap<>(); + + /** + * Cleans up the supplier after each test to ensure test isolation. + *

+ * Setting the supplier to {@code null} restores default environment-variable behavior. + * Clearing the map releases the test key bytes and prevents memory leaks. + *

+ */ + @AfterEach + void cleanup() { + SystemKeyManager.getInstance().setKeySupplier(null); + testKeys.clear(); + } + + /** + * Verifies that {@link SystemKeyManager#getInstance()} returns the same instance + * on every call — confirming the singleton pattern. + */ + @Test + public void testGetInstanceReturnsSingleton() { + SystemKeyManager instance1 = SystemKeyManager.getInstance(); + SystemKeyManager instance2 = SystemKeyManager.getInstance(); + + assertSame(instance1, instance2, "getInstance() should return the same instance"); + } + + /** + * Verifies that {@link SystemKeyManager#getDefaultKey()} returns a valid key + * when a supplier is configured. + */ + @Test + public void testGetDefaultKeyReturnsKey() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + + // Generate a random default key and store it for the test + String base64Key = manager.generateDefaultKey(); + byte[] keyBytes = Base64.getDecoder().decode(base64Key); + testKeys.put(DEFAULT, keyBytes); + + // Inject supplier — all getKey() calls will now return our test key + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + + SecretKey key = manager.getDefaultKey(); + assertNotNull(key, "getDefaultKey() should return a key when supplier is set"); + assertEquals(256, key.getEncoded().length * 8, "Key should be 256 bits"); + assertEquals("AES", key.getAlgorithm(), "Key algorithm should be AES"); + } + + /** + * Verifies that {@link SystemKeyManager#getKey(String)} returns a valid key + * when the supplier is configured with that key name. + */ + @Test + public void testGetKeyReturnsKey() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + + String base64Key = manager.generateKey(DEFAULT); + byte[] keyBytes = Base64.getDecoder().decode(base64Key); + testKeys.put(DEFAULT, keyBytes); + + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + + SecretKey key = manager.getKey(DEFAULT); + assertNotNull(key, "getKey() should return a key when supplier is set"); + } + + /** + * Verifies that {@link SystemKeyManager#getKeyBase64(String)} returns a valid Base64 string + * matching the originally generated key. + */ + @Test + public void testGetKeyBase64ReturnsValidBase64() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + + String base64Key = manager.generateKey(DEFAULT); + byte[] keyBytes = Base64.getDecoder().decode(base64Key); + testKeys.put(DEFAULT, keyBytes); + + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + + String fetchedBase64Key = manager.getKeyBase64(DEFAULT); + assertNotNull(fetchedBase64Key, "getKeyBase64() should return a string after supplier is set"); + assertFalse(fetchedBase64Key.isEmpty(), "Base64 string should not be empty"); + assertEquals(base64Key, fetchedBase64Key); + + byte[] decoded = Base64.getDecoder().decode(fetchedBase64Key); + assertNotNull(decoded, "Base64 should be decodable"); + assertEquals(32, decoded.length, "Decoded key should be 32 bytes (256 bits)"); + } + + /** + * Verifies that keys injected via the supplier are correctly retrievable. + * This test confirms the end-to-end flow: generate → store in map → inject supplier → retrieve. + */ + @Test + public void testEnvironmentVariableIsSet() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateDefaultKey(); + byte[] keyBytes = Base64.getDecoder().decode(base64Key); + testKeys.put(DEFAULT, keyBytes); + + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + + SecretKey key = manager.getKey(DEFAULT); + assertNotNull(key, "Key should be set after using supplier"); + assertEquals(32, key.getEncoded().length, "Decoded key should be 32 bytes (256 bits)"); + } + + /** + * Verifies that the same key is returned consistently across multiple calls. + * This tests that the supplier returns a new SecretKeySpec wrapping the same underlying bytes + * each time — so the encoded content is identical even though the object reference differs. + */ + @Test + public void testKeyConsistencyAcrossInstances() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateDefaultKey(); + byte[] keyBytes = Base64.getDecoder().decode(base64Key); + testKeys.put(DEFAULT, keyBytes); + + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + + SecretKey key1 = manager.getDefaultKey(); + SecretKey key2 = manager.getDefaultKey(); + + assertArrayEquals(key1.getEncoded(), key2.getEncoded(), "Keys should be identical across calls"); + } + + /** + * Verifies that keys injected via the supplier are exactly 256 bits (32 bytes). + */ + @Test + public void testKeyLengthIs256Bits() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + String base64Key = manager.generateDefaultKey(); + byte[] keyBytes = Base64.getDecoder().decode(base64Key); + testKeys.put(DEFAULT, keyBytes); + + manager.setKeySupplier(name -> { + byte[] bytes = testKeys.get(name); + return bytes != null ? new SecretKeySpec(bytes, "AES") : null; + }); + + SecretKey key = manager.getDefaultKey(); + int keyLengthBits = key.getEncoded().length * 8; + assertEquals(256, keyLengthBits, "Key length should be 256 bits"); + } + + /** + * Verifies that the default environment variable name is {@code JPOS_ENCRYPTION_KEY}. + */ + @Test + public void testGetEnvVarName() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + + String envVar = manager.getEnvVarName(DEFAULT); + assertEquals("JPOS_ENCRYPTION_KEY", envVar, "Default env var name should be JPOS_ENCRYPTION_KEY"); + } + + /** + * Verifies the environment variable naming convention: + *
    + *
  • Default key → {@code JPOS_ENCRYPTION_KEY}
  • + *
  • "db" → {@code JPOS_ENCRYPTION_KEY_DB}
  • + *
  • "my-key" → {@code JPOS_ENCRYPTION_KEY_MY_KEY} (hyphens normalized to underscores)
  • + *
  • "api_key 123!" → {@code JPOS_ENCRYPTION_KEY_API_KEY_123_} (special chars replaced)
  • + *
+ */ + @Test + public void testGetEnvVarNameSanitization() { + SystemKeyManager manager = SystemKeyManager.getInstance(); + + assertEquals("JPOS_ENCRYPTION_KEY", manager.getEnvVarName(DEFAULT)); + assertEquals("JPOS_ENCRYPTION_KEY_DB", manager.getEnvVarName("db")); + assertEquals("JPOS_ENCRYPTION_KEY_MY_KEY", manager.getEnvVarName("my-key")); + assertEquals("JPOS_ENCRYPTION_KEY_API_KEY_123_", manager.getEnvVarName("api_key 123!")); + assertEquals("JPOS_ENCRYPTION_KEY_XYZ_", manager.getEnvVarName("XYZ#")); // Testing user's exact example + } + +}