PasswordCrypto.java
package com.bonitasoft.processbuilder.extension;
import javax.crypto.Cipher;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
/**
* Utility class for secure password encryption and decryption.
* <p>
* Uses the environment variable {@code MASTER_BONITA_PWD} as the master password
* to derive encryption keys using PBKDF2. This approach eliminates the need to
* store encryption keys in the database.
* </p>
*
* <p><b>Usage:</b></p>
* <pre>{@code
* // Encrypt a password before storing in database
* String encrypted = PasswordCrypto.encrypt("myPassword123");
*
* // Decrypt when needed
* String decrypted = PasswordCrypto.decrypt(encrypted);
*
* // Safe methods that check if already encrypted/decrypted
* String safeEncrypted = PasswordCrypto.encryptIfNeeded(text);
* String safeDecrypted = PasswordCrypto.decryptIfNeeded(text);
* }</pre>
*
* <p><b>Configuration:</b></p>
* <p>Set the environment variable before starting the server:</p>
* <pre>{@code
* export MASTER_BONITA_PWD="YourSecureMasterPassword123!"
* }</pre>
*
* @author Bonitasoft
* @since 1.0
*/
public final class PasswordCrypto {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final String KEY_ALGORITHM = "AES";
private static final String KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256";
private static final int KEY_LENGTH_BITS = 256;
private static final int GCM_IV_LENGTH_BYTES = 12;
private static final int GCM_TAG_LENGTH_BITS = 128;
private static final int SALT_LENGTH_BYTES = 16;
private static final int PBKDF2_ITERATIONS = 310_000;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/**
* Environment variable name for the master password.
*/
public static final String ENV_VAR_NAME = "MASTER_BONITA_PWD";
/**
* Minimum length for encrypted Base64 output (salt + iv + tag + minimal data).
*/
private static final int MIN_ENCRYPTED_LENGTH = 60;
private PasswordCrypto() {
throw new UnsupportedOperationException(
"This is a " + this.getClass().getSimpleName() + " class and cannot be instantiated."
);
}
/**
* Encrypts the given text using the master password from environment variable.
*
* @param plainText the text to encrypt (must not be null)
* @return the encrypted text as Base64 string
* @throws IllegalArgumentException if plainText is null
* @throws CryptoException if master password is not configured or encryption fails
*/
public static String encrypt(String plainText) {
if (plainText == null) {
throw new IllegalArgumentException("Plain text cannot be null");
}
return encryptWithPassword(plainText, getMasterPassword());
}
/**
* Encrypts the given text using the provided master password.
* <p>
* Package-private for testing purposes.
* </p>
*
* @param plainText the text to encrypt (must not be null)
* @param masterPassword the master password to use for encryption
* @return the encrypted text as Base64 string
* @throws IllegalArgumentException if plainText is null
* @throws CryptoException if encryption fails
*/
static String encryptWithPassword(String plainText, String masterPassword) {
if (plainText == null) {
throw new IllegalArgumentException("Plain text cannot be null");
}
try {
byte[] salt = generateRandomBytes(SALT_LENGTH_BYTES);
byte[] iv = generateRandomBytes(GCM_IV_LENGTH_BYTES);
var key = deriveKey(masterPassword, salt);
var cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
ByteBuffer buffer = ByteBuffer.allocate(salt.length + iv.length + cipherText.length);
buffer.put(salt);
buffer.put(iv);
buffer.put(cipherText);
return Base64.getEncoder().encodeToString(buffer.array());
} catch (GeneralSecurityException e) {
throw new CryptoException("Encryption failed", e);
}
}
/**
* Decrypts the given encrypted text using the master password from environment variable.
*
* @param encryptedText the Base64 encrypted text to decrypt
* @return the decrypted plain text
* @throws IllegalArgumentException if encryptedText is null or empty
* @throws CryptoException if master password is not configured or decryption fails
*/
public static String decrypt(String encryptedText) {
if (encryptedText == null || encryptedText.isBlank()) {
throw new IllegalArgumentException("Encrypted text cannot be null or empty");
}
return decryptWithPassword(encryptedText, getMasterPassword());
}
/**
* Decrypts the given encrypted text using the provided master password.
* <p>
* Package-private for testing purposes.
* </p>
*
* @param encryptedText the Base64 encrypted text to decrypt
* @param masterPassword the master password to use for decryption
* @return the decrypted plain text
* @throws IllegalArgumentException if encryptedText is null or empty
* @throws CryptoException if decryption fails
*/
static String decryptWithPassword(String encryptedText, String masterPassword) {
if (encryptedText == null || encryptedText.isBlank()) {
throw new IllegalArgumentException("Encrypted text cannot be null or empty");
}
try {
byte[] decoded = Base64.getDecoder().decode(encryptedText);
int minLength = SALT_LENGTH_BYTES + GCM_IV_LENGTH_BYTES + 1;
if (decoded.length < minLength) {
throw new CryptoException("Invalid encrypted data: too short");
}
ByteBuffer buffer = ByteBuffer.wrap(decoded);
byte[] salt = new byte[SALT_LENGTH_BYTES];
buffer.get(salt);
byte[] iv = new byte[GCM_IV_LENGTH_BYTES];
buffer.get(iv);
byte[] cipherText = new byte[buffer.remaining()];
buffer.get(cipherText);
var key = deriveKey(masterPassword, salt);
var cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw new CryptoException("Invalid Base64 encoded input", e);
} catch (GeneralSecurityException e) {
throw new CryptoException("Decryption failed: wrong master password or corrupted data", e);
}
}
/**
* Encrypts the text only if it does not appear to be already encrypted.
*
* @param text the text to encrypt
* @return the encrypted text, or the original if null/empty/already encrypted
*/
public static String encryptIfNeeded(String text) {
if (text == null || text.isBlank()) {
return text;
}
if (isEncrypted(text)) {
return text;
}
return encrypt(text);
}
/**
* Decrypts the text only if it appears to be encrypted.
*
* @param text the text to decrypt
* @return the decrypted text, or the original if null/empty/not encrypted
*/
public static String decryptIfNeeded(String text) {
if (text == null || text.isBlank()) {
return text;
}
if (!isEncrypted(text)) {
return text;
}
try {
return decrypt(text);
} catch (CryptoException e) {
return text;
}
}
/**
* Checks if the master password environment variable is configured.
*
* @return true if configured, false otherwise
*/
public static boolean isMasterPasswordConfigured() {
String masterPassword = System.getenv(ENV_VAR_NAME);
return masterPassword != null && !masterPassword.isBlank();
}
/**
* Checks if the given text appears to be encrypted.
* <p>
* This is a heuristic check based on Base64 format and minimum length.
* </p>
*
* @param text the text to check
* @return true if the text appears to be encrypted
*/
public static boolean isEncrypted(String text) {
if (text == null || text.isBlank() || text.length() < MIN_ENCRYPTED_LENGTH) {
return false;
}
return text.matches("^[A-Za-z0-9+/]+=*$");
}
private static String getMasterPassword() {
String masterPassword = System.getenv(ENV_VAR_NAME);
if (masterPassword == null || masterPassword.isBlank()) {
throw new CryptoException(
"Master password not configured. Set environment variable: " + ENV_VAR_NAME
);
}
return masterPassword;
}
private static SecretKeySpec deriveKey(String password, byte[] salt) throws GeneralSecurityException {
var spec = new PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH_BITS);
var factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM);
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
return new SecretKeySpec(keyBytes, KEY_ALGORITHM);
}
private static byte[] generateRandomBytes(int length) {
byte[] bytes = new byte[length];
SECURE_RANDOM.nextBytes(bytes);
return bytes;
}
/**
* Exception thrown when cryptographic operations fail.
*/
public static class CryptoException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Constructs a new CryptoException with the specified detail message.
*
* @param message the detail message
*/
public CryptoException(String message) {
super(message);
}
/**
* Constructs a new CryptoException with the specified detail message and cause.
*
* @param message the detail message
* @param cause the cause of the exception
*/
public CryptoException(String message, Throwable cause) {
super(message, cause);
}
}
}