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
+ * Format: {@code enc::
+ * 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.).
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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)}:
+ *
+ *
+ */
+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 Key Loading Strategy
+ * Key Rotation Limitation
+ * Key Naming Convention
+ *
+ *
+ * Non-alphanumeric characters in the key name are normalized to underscores.
+ *
+ * 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. + *
+ * + *+ * 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. + *
+ *+ * 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. + *
+ *+ * 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. + *
+ *+ * 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). + *
+ *{@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: + *
{@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: + *
+ * 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. + *
+ *+ * 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. + *
+ *+ * 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. + *
+ *+ * 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
+ * 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
+ * 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
+ * }
+ *
+ * + * 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+ * 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+ * 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). + *
+ *+ * 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+ * 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::"). + *
+ *+ * The test: + *
+ * 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+ * This test simulates the real-world scenario where: + *
+ * 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. + *
+ *+ * 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+ * The flow is: + *
+ * This test verifies that {@code SimpleConfiguration} correctly resolves encrypted + * properties when they reference other system properties containing encrypted values. + *
+ *+ * The test creates a scenario where: + *
+ * 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+ * The flow: + *
+ * These tests verify key generation, resolution, and the environment variable naming convention. + *
+ *+ * 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: + *
+ *+ * 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+ * 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: + *