/* Copyright 2005-2006 Tim Fennell
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.sourceforge.stripes.util;
import java.security.MessageDigest;
import java.security.SecureRandom;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESedeKeySpec;
import javax.crypto.spec.IvParameterSpec;
import net.sourceforge.stripes.config.Configuration;
import net.sourceforge.stripes.controller.StripesFilter;
import net.sourceforge.stripes.exception.StripesRuntimeException;
/**
* <p>Cryptographic utility that can encrypt and decrypt Strings using a key stored in
* HttpSession. Strings are encrypted by default using a 168bit DEDede (triple DES) key
* and then Base 64 encoded in a way that is compatible with being inserte into web pages.</p>
*
* <p>A single encryption key is used to encrypt values for all sessions in the web application.
* The key can come from multiple sources. Without any configuration the key will be generated
* using a SecureRandom the first time it is needed. <b>Note: this will result in encrypted
* values that are not decryptable across application restarts or across nodes in a cluster.</b>
* Alternatively specific key material can be specified using the configuration parameter
* <code>Stripes.EncryptionKey</code> in web.xml. This key is text that is used to generate
* a secret key, and ideally should be quite long (at least 20 characters). If a key is
* configured this way the same key will be used across all nodes in a cluster and across
* restarts.</p>
*
* <p>Finally a key can be specified by calling {@link #setSecretKey(javax.crypto.SecretKey)} and
* providing your own {@link SecretKey} instance. This method allows the specification of any
* key from any source. In addition the provided key can be for any algorithm supported by
* the JVM in which it is constructed. CryptoUtil will then use the algorithm returned by
* {@link javax.crypto.SecretKey#getAlgorithm()}. If using this method, the key should be set
* before any requests are made, e.g. in a {@link javax.servlet.ServletContextListener}.</p>
*
* <p>Stripes originally performed a broken authentication scheme. It was rewritten in STS-934
* to perform the Encrypt-then-Mac pattern. Also the encryption mode was changed from ECB to CBC.</p>
*
* @author Tim Fennell
* @since Stripes 1.2
* @see https://en.wikipedia.org/wiki/Authenticated_encryption
*/
public class CryptoUtil {
private static final Log log = Log.getInstance(CryptoUtil.class);
/** The algorithm that is used to encrypt values. */
protected static final String ALGORITHM = "DESede";
protected static final String CIPHER_MODE_MODIFIER = "/CBC/PKCS5Padding";
protected static final int CIPHER_BLOCK_LENGTH = 8;
private static final String CIPHER_HMAC_ALGORITHM = "HmacSHA256";
private static final int CIPHER_HMAC_LENGTH = 32;
/** Key used to look up the location of a secret key. */
public static final String CONFIG_ENCRYPTION_KEY = "Stripes.EncryptionKey";
/** Minimum number of bytes to raise the key material to before generating a key. */
private static final int MIN_KEY_BYTES = 128;
/** The options used for Base64 Encoding. */
private static final int BASE64_OPTIONS = Base64.URL_SAFE | Base64.DONT_BREAK_LINES;
/** Secret key to be used o encrypt and decrypt values. */
private static SecretKey secretKey;
/**
* Takes in a String, encrypts it and then base64 encodes the resulting byte[] so that it can be
* transmitted and stored as a String. Can be decrypted by a subsequent call to
* {@link #decrypt(String)}. Because, null and "" are equivalent to the Stripes binding engine,
* if {@code input} is null, then it will be encrypted as if it were "".
*
* @param input the String to encrypt and encode
* @return the encrypted, base64 encoded String
*/
public static String encrypt(String input) {
if (input == null)
input = "";
// encryption is disabled in debug mode
Configuration configuration = StripesFilter.getConfiguration();
if (configuration != null && configuration.isDebugMode())
return input;
try {
byte[] inbytes = input.getBytes();
final int inputLength = inbytes.length;
byte[] output = new byte[ calculateCipherbytes(inputLength) + CIPHER_HMAC_LENGTH ];
//key required by cipher and hmac
SecretKey key = getSecretKey();
/*
* Generate an initialization vector required by block cipher modes
*/
byte[] iv = generateInitializationVector();
System.arraycopy(iv, 0, output, 0, CIPHER_BLOCK_LENGTH);
/*
* Encrypt-then-Mac (EtM) pattern, first encrypt plaintext
*/
Cipher cipher = getCipher(key, Cipher.ENCRYPT_MODE, iv, 0, CIPHER_BLOCK_LENGTH);
cipher.doFinal(inbytes, 0, inbytes.length, output, CIPHER_BLOCK_LENGTH);
/*
* Encrypt-then-Mac (EtM) pattern, authenticate ciphertext
*/
hmac(key, output, 0, output.length - CIPHER_HMAC_LENGTH, output, output.length - CIPHER_HMAC_LENGTH);
// Then base64 encode the bytes
return Base64.encodeBytes(output, BASE64_OPTIONS);
}
catch (Exception e) {
throw new StripesRuntimeException("Could not encrypt value.", e);
}
}
/**
* Generates IV, random start bytes required by most block cipher modes,
* which is intended to prevent analyzing a cipher mode as an xor substitution cipher.
*
* @return CIPHER_BLOCK_LENGTH bytes of random data
*/
private static byte[] generateInitializationVector() {
// always create a new SecureRandom; get new OS/JVM random bytes instead of cycling the prng.
SecureRandom random = new SecureRandom();
byte[] iv = new byte[CIPHER_BLOCK_LENGTH];
random.nextBytes(iv);
return iv;
}
/**
* Performs keyed authentication using HMAC.
* Note: When building ciphertext+hmac array, data and mac will be the same array, and dataLength == macPos.
* @param key the authentication key
* @param data the data to be authenticated
* @param dataPos start of data to be authenticated
* @param dataLength the number of bytes to be authenticated
* @param mac the array which holds the resulting hmac
* @param macPos the position to write the hmac to
*/
private static void hmac(SecretKey key, byte[] data, int dataPos, int dataLength, byte[] mac, int macPos) throws Exception {
Mac m = Mac.getInstance(CIPHER_HMAC_ALGORITHM);
m.init(key);
m.update(data, dataPos, dataLength);
m.doFinal(mac, macPos);
}
/**
* Returns the ciphertext length for a given plaintext,
* @param inputLength the length of plaintext
* @return the length of ciphertext, calculated based on blockcipher block size
*/
private static int calculateCipherbytes(int inputLength) {
// 2 = IV + last block (including padding)
int blocks = 2 + (inputLength/CIPHER_BLOCK_LENGTH);
return blocks * CIPHER_BLOCK_LENGTH;
}
/**
* Takes in a base64 encoded and encrypted String that was generated by a call to
* {@link #encrypt(String)} and decrypts it. If {@code input} is null, then null will be
* returned.
*
* @param input the base64 String to decode and decrypt
* @return the decrypted String
*/
public static String decrypt(String input) {
if (input == null)
return null;
// encryption is disabled in debug mode
Configuration configuration = StripesFilter.getConfiguration();
if (configuration != null && configuration.isDebugMode())
return input;
// First un-base64 the String
byte[] bytes = Base64.decode(input, BASE64_OPTIONS);
if (bytes == null || bytes.length < 1) {
log.warn("Input is not Base64 encoded: ", input);
return null;
}
if (bytes.length < CIPHER_BLOCK_LENGTH * 2 + CIPHER_HMAC_LENGTH) {
log.warn("Input is too short: ", input);
return null;
}
SecretKey key = getSecretKey();
/*
* HMAC: validate ciphertext integrity.
* invalid hmac = choosen ciphertext attack against system.
*
* Encrypt-then-Mac (EtM) pattern, HMAC must be validated before the dangerous decrypt operation.
*
*/
byte[] mac = new byte[CIPHER_HMAC_LENGTH];
try {
hmac(key, bytes, 0, bytes.length - CIPHER_HMAC_LENGTH, mac, 0);
} catch (Exception e1) {
log.warn("Unexpected error performing hmac on: ", input);
return null;
}
boolean validCiphertext;
try {
validCiphertext = hmacEquals(key, bytes, bytes.length - CIPHER_HMAC_LENGTH, mac, 0);
} catch (Exception e1) {
log.warn("Unexpected error validating hmac of: ", input);
return null;
}
if (!validCiphertext) {
log.warn("Input was not encrypted with the current encryption key (bad HMAC): ", input);
return null;
}
/*
* Encrypt-then-Mac pattern;
* If validation success, ciphertext is assumed to be friendly and safe to process.
* Padding attacks, wrong blocklength etc is not expected from this point.
*
*/
// Then fetch a cipher and decrypt the bytes
Cipher cipher = getCipher(key, Cipher.DECRYPT_MODE, bytes, 0, CIPHER_BLOCK_LENGTH);
byte[] output;
try {
output = cipher.doFinal(bytes, CIPHER_BLOCK_LENGTH, bytes.length - CIPHER_HMAC_LENGTH - CIPHER_BLOCK_LENGTH);
}
catch (IllegalBlockSizeException e) {
log.warn("Unexpected IllegalBlockSizeException on: ", input);
return null;
}
catch (BadPaddingException e) {
log.warn("Unexpected BadPaddingException on: ", input);
return null;
}
return new String(output);
}
/**
* Compares HMAC in a manner secured against timing attacks, as per NCC Group "Double Hmac Verification" recepie.
* Destructive compare, the hmac's will be replaced with the hmac's of themselves as a side effect.
* @param key the hmac crypto key
* @param mac1 the array which contains the hmac
* @param mac1pos the position of the hmac in mac1 array.
* @param mac2 the array which contains the hmac
* @param mac2pos the position of the hmac in mac2 array.
* @return true if hmacs are equal, otherwise false
* @see double hmac as per https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/
*/
private static boolean hmacEquals(SecretKey key, byte[] mac1, int mac1pos,
byte[] mac2, int mac2pos) throws Exception {
hmac(key, mac1, mac1pos, CIPHER_HMAC_LENGTH, mac1, mac1pos);
hmac(key, mac2, mac2pos, CIPHER_HMAC_LENGTH, mac2, mac2pos);
for(int i = 0; i < CIPHER_HMAC_LENGTH; i++)
if (mac1[mac1pos+i] != mac2[mac2pos+i])
return false;
return true;
}
/**
* Generates a cipher based on a key and an initialization vector
* @param key the crypto key
* @param mode Cipher.ENCRYPT_MODE or Cipher.DECRYPT_MODE
* @param iv the initialization vector
* @param ivpos the start position of the initialization vector, typically 0
* @param ivlength the length of the initialization vector
* @return the cipher object
* @see Cipher#ENCRYPT_MODE
* @see Cipher#DECRYPT_MODE
*/
protected static Cipher getCipher(SecretKey key, int mode, byte[] iv, int ivpos, int ivlength) {
try {
// Then build a cipher for the correct mode
Cipher cipher = Cipher.getInstance(key.getAlgorithm() + CIPHER_MODE_MODIFIER);
IvParameterSpec ivps = new IvParameterSpec(iv, ivpos, ivlength);
cipher.init(mode, key, ivps);
return cipher;
}
catch (Exception e) {
throw new StripesRuntimeException("Could not generate a Cipher.", e);
}
}
/**
* Returns the secret key to be used to encrypt and decrypt values. The key will be generated
* the first time it is requested. Will look for source material for the key in config and
* use that if found. Otherwise will generate key material using a SecureRandom and then
* manufacture the key. Once the key is created it is cached locally and the
* same key instance will be returned until the application is shutdown or restarted.
*
* @return SecretKey the secret key used to encrypt and decrypt values
*/
protected static synchronized SecretKey getSecretKey() {
try {
if (CryptoUtil.secretKey == null) {
// Check to see if a key location was specified in config
byte[] material = getKeyMaterialFromConfig();
// If there wasn't a key string in config, make one
if (material == null) {
material = new byte[MIN_KEY_BYTES];
new SecureRandom().nextBytes(material);
}
// Hash the key string given in config
else {
MessageDigest digest = MessageDigest.getInstance("SHA1");
int length = digest.getDigestLength();
byte[] hashed = new byte[MIN_KEY_BYTES];
for (int i = 0; i < hashed.length; i += length) {
material = digest.digest(material);
System.arraycopy(material, 0, hashed, i,
Math.min(length, MIN_KEY_BYTES - i));
}
material = hashed;
}
// Now manufacture the actual Secret Key instance
SecretKeyFactory factory = SecretKeyFactory.getInstance(CryptoUtil.ALGORITHM);
CryptoUtil.secretKey = factory.generateSecret(new DESedeKeySpec(material));
}
}
catch (Exception e) {
throw new StripesRuntimeException("Could not generate a secret key.", e);
}
return CryptoUtil.secretKey;
}
/**
* Attempts to load material from which to manufacture a secret key from the Stripes
* Configuration. If config is unavailable or there is no material configured null
* will be returned.
*
* @return a byte[] of key material, or null
*/
protected static byte[] getKeyMaterialFromConfig() {
try {
Configuration config = StripesFilter.getConfiguration();
if (config != null) {
String key = config.getBootstrapPropertyResolver().getProperty(CONFIG_ENCRYPTION_KEY);
if (key != null) {
return key.getBytes();
}
}
}
catch (Exception e) {
log.warn("Could not load key material from configuration.", e);
}
return null;
}
/**
* Sets the secret key that will be used by the CryptoUtil to perform encryption
* and decryption. In general the use of the config property (Stripes.EncryptionKey)
* should be preferred, but if specific encryption methods are required, this method
* allows the caller to set a SecretKey suitable to any symmetric encryption algorithm
* available in the JVM.
*
* @param key the secret key to be used to encrypt and decrypt values going forward
*/
public static synchronized void setSecretKey(SecretKey key) {
CryptoUtil.secretKey = key;
}
}