Skip to content

Commit 7ad0162

Browse files
committed
feat(core): Implement named key support for AES-256-GCM encryption
- Renamed prefix from crypto:: to enc:: for consistency - Added SystemKeyManager class to manage encryption keys derived from SystemSeed - Implemented named key support (enc::<keyname>::) for multiple encryption contexts - Updated CryptoEnvironmentProvider to use SystemKeyManager for key retrieval - Added CRYPTO CLI command with generate and encrypt subcommands - Added comprehensive tests for named key functionality - Updated documentation in CRYPTO_IMPLEMENTATION.md - Replaced ISOUtil import with direct SystemSeed usage - All tests passing (18 CryptoEnvironmentProviderTest, 4 CRYPTOTest, 13 SystemKeyManagerTest, 1 DeployWithEncryptedPropsTest)
1 parent 1c10ff2 commit 7ad0162

File tree

8 files changed

+902
-81
lines changed

8 files changed

+902
-81
lines changed

jpos/src/main/java/org/jpos/core/CryptoEnvironmentProvider.java

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@
1818

1919
package org.jpos.core;
2020

21-
import org.jpos.iso.ISOUtil;
2221
import org.jpos.security.SystemSeed;
2322

2423
import javax.crypto.Cipher;
24+
import javax.crypto.SecretKey;
2525
import javax.crypto.spec.GCMParameterSpec;
2626
import javax.crypto.spec.SecretKeySpec;
2727
import java.nio.ByteBuffer;
@@ -32,7 +32,7 @@
3232
* EnvironmentProvider that encrypts/decrypts values using AES256.
3333
*
3434
* <p>
35-
* Format: {@code crypto::<base64-encoded-ciphertext>}
35+
* Format: {@code enc::<base64-encoded-ciphertext>}
3636
* <ul>
3737
* <li>Algorithm: AES-256-GCM with authenticated encryption</li>
3838
* <li>Key: 32 bytes derived from SystemSeed using SHA-256</li>
@@ -49,16 +49,25 @@ public class CryptoEnvironmentProvider implements EnvironmentProvider {
4949

5050
@Override
5151
public String prefix() {
52-
return "crypto::";
52+
return "enc::";
5353
}
5454

5555
@Override
5656
public String get(String config) {
5757
try {
58-
// Remove the crypto:: prefix if present
58+
String keyName = null;
5959
String encoded = config;
60-
if (config.startsWith("crypto::")) {
61-
encoded = config.substring(8);
60+
61+
// Check for key name prefix: enc::keyname::encoded_data
62+
if (config.startsWith("enc::")) {
63+
String[] parts = config.substring(5).split("::", 2);
64+
if (parts.length == 2) {
65+
keyName = parts[0];
66+
encoded = parts[1];
67+
} else {
68+
// Default key
69+
encoded = config.substring(5);
70+
}
6271
}
6372

6473
byte[] decoded = Base64.getDecoder().decode(encoded);
@@ -72,9 +81,14 @@ public String get(String config) {
7281
byte[] ciphertext = new byte[buf.remaining()];
7382
buf.get(ciphertext);
7483

75-
// Use first 32 bytes of SystemSeed as the 256-bit AES key
76-
byte[] seed = SystemSeed.getSeed(0, 32);
77-
SecretKeySpec keySpec = new SecretKeySpec(seed, ALGORITHM);
84+
// Use SystemKeyManager to get the derived key
85+
SecretKey key = SystemKeyManager.getInstance().getKey(keyName);
86+
if (key == null) {
87+
throw new RuntimeException("Key not found in environment for name: " +
88+
(keyName != null && !keyName.isEmpty() ? keyName : "default") +
89+
". Please set " + SystemKeyManager.getInstance().getEnvVarName(keyName));
90+
}
91+
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), ALGORITHM);
7892

7993
// Decrypt with GCM authentication
8094
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
@@ -92,13 +106,28 @@ public String get(String config) {
92106
* Helper method to encrypt a value (for generating encrypted config).
93107
*
94108
* @param value the plaintext value to encrypt
95-
* @return base64-encoded ciphertext with crypto:: prefix
109+
* @return base64-encoded ciphertext with enc:: prefix
96110
*/
97111
public static String encrypt(String value) {
112+
return encrypt(value, null);
113+
}
114+
115+
/**
116+
* Helper method to encrypt a value with a named key.
117+
*
118+
* @param value the plaintext value to encrypt
119+
* @param keyName the name of the key to use (null for default)
120+
* @return base64-encoded ciphertext with enc::keyname:: prefix
121+
*/
122+
public static String encrypt(String value, String keyName) {
98123
try {
99-
// Use first 32 bytes of SystemSeed as the 256-bit AES key
100-
byte[] seed = SystemSeed.getSeed(0, 32);
101-
SecretKeySpec keySpec = new SecretKeySpec(seed, ALGORITHM);
124+
SecretKey key = SystemKeyManager.getInstance().getKey(keyName);
125+
if (key == null) {
126+
throw new IllegalArgumentException("Key not found in environment for name: " +
127+
(keyName != null && !keyName.isEmpty() ? keyName : "default") +
128+
". Please set " + SystemKeyManager.getInstance().getEnvVarName(keyName));
129+
}
130+
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), ALGORITHM);
102131

103132
// Generate secure random 12-byte IV
104133
byte[] iv = new byte[IV_SIZE_BYTES];
@@ -115,7 +144,14 @@ public static String encrypt(String value) {
115144
buf.put(iv);
116145
buf.put(ciphertext);
117146

118-
return "crypto::" + Base64.getEncoder().encodeToString(buf.array());
147+
String base64 = Base64.getEncoder().encodeToString(buf.array());
148+
149+
// If keyName is provided, include it in the prefix
150+
if (keyName != null && !keyName.isEmpty()) {
151+
return "enc::" + keyName + "::" + base64;
152+
} else {
153+
return "enc::" + base64;
154+
}
119155
} catch (Exception e) {
120156
throw new RuntimeException("Failed to encrypt value", e);
121157
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/*
2+
* jPOS Project [http://jpos.org]
3+
* Copyright (C) 2000-2026 jPOS Software SRL
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as
7+
* published by the Free Software Foundation, either version 3 of the
8+
* License, or (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
package org.jpos.core;
20+
21+
import org.jpos.security.SystemSeed;
22+
23+
import javax.crypto.Cipher;
24+
import javax.crypto.KeyGenerator;
25+
import javax.crypto.SecretKey;
26+
import javax.crypto.spec.GCMParameterSpec;
27+
import javax.crypto.spec.SecretKeySpec;
28+
import java.security.SecureRandom;
29+
import java.util.Base64;
30+
31+
/**
32+
* Manages encryption keys derived from SystemSeed.
33+
*
34+
* <p>
35+
* This class:
36+
* <ul>
37+
* <li>Derives a consistent key from SystemSeed (32 bytes = 256 bits)</li>
38+
* <li>Provides methods to encrypt/decrypt data using these keys</li>
39+
* <li>Loads keys strictly from environment variables (no internal caching)</li>
40+
* </ul>
41+
*/
42+
public class SystemKeyManager {
43+
private static final String DEFAULT_KEY_NAME = "default";
44+
private static final String DEFAULT_ENV_VAR = "JPOS_ENCRYPTION_KEY";
45+
private static final int KEY_SIZE_BITS = 256;
46+
47+
private static final SystemKeyManager instance;
48+
49+
static {
50+
try {
51+
instance = new SystemKeyManager();
52+
} catch (Exception e) {
53+
throw new RuntimeException("Failed to initialize SystemKeyManager", e);
54+
}
55+
}
56+
57+
private SystemKeyManager() {
58+
}
59+
60+
/**
61+
* Returns the singleton SystemKeyManager instance.
62+
*
63+
* @return the SystemKeyManager instance
64+
*/
65+
public static SystemKeyManager getInstance() {
66+
return instance;
67+
}
68+
69+
/**
70+
* Gets a key by name. Returns null if key doesn't exist in environment.
71+
* Never caches the key.
72+
*
73+
* @param keyName the name of the key to get
74+
* @return the SecretKey, or null if not found
75+
*/
76+
public SecretKey getKey(String keyName) {
77+
if (keyName == null || keyName.isEmpty()) {
78+
keyName = DEFAULT_KEY_NAME;
79+
}
80+
81+
String envVarName = getEnvVarName(keyName);
82+
String envValue = System.getenv(envVarName);
83+
84+
// Fallback for test environments where setting System.getenv is not possible
85+
if (envValue == null || envValue.trim().isEmpty()) {
86+
envValue = System.getProperty(envVarName);
87+
}
88+
89+
if (envValue != null && !envValue.trim().isEmpty()) {
90+
try {
91+
byte[] keyBytes = Base64.getDecoder().decode(envValue.trim());
92+
if (keyBytes.length == KEY_SIZE_BITS / 8) {
93+
return new SecretKeySpec(keyBytes, "AES");
94+
}
95+
} catch (IllegalArgumentException e) {
96+
// Invalid Base64 or length
97+
}
98+
}
99+
100+
return null;
101+
}
102+
103+
/**
104+
* Gets the default key. Returns null if key doesn't exist.
105+
*
106+
* @return the default SecretKey, or null if not found
107+
*/
108+
public SecretKey getDefaultKey() {
109+
return getKey(DEFAULT_KEY_NAME);
110+
}
111+
112+
/**
113+
* Gets the Base64-encoded key by name.
114+
*
115+
* @param keyName the name of the key
116+
* @return Base64-encoded key, or null if not found
117+
*/
118+
public String getKeyBase64(String keyName) {
119+
SecretKey key = getKey(keyName);
120+
return key != null ? Base64.getEncoder().encodeToString(key.getEncoded()) : null;
121+
}
122+
123+
/**
124+
* Generates a new key from SystemSeed and returns its Base64 string.
125+
* The user is responsible for setting the environment variable manually.
126+
*
127+
* @param keyName the name to give the key
128+
* @return the generated key in Base64
129+
*/
130+
public String generateKey(String keyName) {
131+
try {
132+
byte[] seed = SystemSeed.getSeed(0, 32);
133+
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
134+
keyGen.init(KEY_SIZE_BITS, new SecureRandom(seed));
135+
SecretKey key = keyGen.generateKey();
136+
return Base64.getEncoder().encodeToString(key.getEncoded());
137+
} catch (Exception e) {
138+
throw new RuntimeException("Failed to generate key from SystemSeed", e);
139+
}
140+
}
141+
142+
/**
143+
* Generates a new default key from SystemSeed.
144+
*
145+
* @return the Base64-encoded generated key
146+
*/
147+
public String generateDefaultKey() {
148+
return generateKey(DEFAULT_KEY_NAME);
149+
}
150+
151+
private static final int IV_SIZE_BYTES = 12;
152+
private static final int TAG_LENGTH_BITS = 128;
153+
154+
/**
155+
* Encrypts data using the default key.
156+
*
157+
* @param data the data to encrypt
158+
* @return encrypted data (with IV prepended)
159+
*/
160+
public byte[] encrypt(byte[] data) {
161+
return encrypt(data, DEFAULT_KEY_NAME);
162+
}
163+
164+
/**
165+
* Encrypts data using a named key.
166+
*
167+
* @param data the data to encrypt
168+
* @param keyName the name of the key to use
169+
* @return encrypted data (with IV prepended)
170+
*/
171+
public byte[] encrypt(byte[] data, String keyName) {
172+
try {
173+
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
174+
SecretKey key = getKey(keyName);
175+
176+
if (key == null) {
177+
throw new IllegalArgumentException("No key found in environment for name: " +
178+
(keyName != null && !keyName.isEmpty() ? keyName : DEFAULT_KEY_NAME) + ". Please set " + getEnvVarName(keyName));
179+
}
180+
181+
byte[] iv = new byte[IV_SIZE_BYTES];
182+
new SecureRandom().nextBytes(iv);
183+
184+
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(TAG_LENGTH_BITS, iv);
185+
cipher.init(Cipher.ENCRYPT_MODE, key, gcmParameterSpec);
186+
byte[] ciphertext = cipher.doFinal(data);
187+
188+
byte[] result = new byte[iv.length + ciphertext.length];
189+
System.arraycopy(iv, 0, result, 0, iv.length);
190+
System.arraycopy(ciphertext, 0, result, iv.length, ciphertext.length);
191+
192+
return result;
193+
} catch (Exception e) {
194+
throw new RuntimeException("Encryption failed", e);
195+
}
196+
}
197+
198+
/**
199+
* Decrypts data using the default key.
200+
*
201+
* @param encryptedData the encrypted data (with IV prepended)
202+
* @return decrypted data
203+
*/
204+
public byte[] decrypt(byte[] encryptedData) {
205+
return decrypt(encryptedData, DEFAULT_KEY_NAME);
206+
}
207+
208+
/**
209+
* Decrypts data using a named key.
210+
*
211+
* @param encryptedData the encrypted data (with IV prepended)
212+
* @param keyName the name of the key to use
213+
* @return decrypted data
214+
*/
215+
public byte[] decrypt(byte[] encryptedData, String keyName) {
216+
try {
217+
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
218+
SecretKey key = getKey(keyName);
219+
220+
if (key == null) {
221+
throw new IllegalArgumentException("No key found in environment for name: " +
222+
(keyName != null && !keyName.isEmpty() ? keyName : DEFAULT_KEY_NAME) + ". Please set " + getEnvVarName(keyName));
223+
}
224+
225+
byte[] iv = new byte[IV_SIZE_BYTES];
226+
System.arraycopy(encryptedData, 0, iv, 0, iv.length);
227+
228+
byte[] ciphertext = new byte[encryptedData.length - iv.length];
229+
System.arraycopy(encryptedData, iv.length, ciphertext, 0, ciphertext.length);
230+
231+
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(TAG_LENGTH_BITS, iv);
232+
cipher.init(Cipher.DECRYPT_MODE, key, gcmParameterSpec);
233+
return cipher.doFinal(ciphertext);
234+
} catch (Exception e) {
235+
throw new RuntimeException("Decryption failed", e);
236+
}
237+
}
238+
239+
/**
240+
* Gets the environment variable name for a key.
241+
*
242+
* @param keyName the name of the key
243+
* @return the environment variable name
244+
*/
245+
public String getEnvVarName(String keyName) {
246+
if (keyName == null || keyName.isEmpty()) {
247+
keyName = DEFAULT_KEY_NAME;
248+
}
249+
return DEFAULT_ENV_VAR + (DEFAULT_KEY_NAME.equals(keyName) ? "" : "_" + keyName.toUpperCase());
250+
}
251+
252+
/**
253+
* Clears all keys (for testing purposes).
254+
*/
255+
public void clearKeys() {
256+
// No-op as internal cache is removed
257+
}
258+
}

0 commit comments

Comments
 (0)