/*
* Licensed to Jasig under one or more contributor license
* agreements. See the NOTICE file distributed with this work
* for additional information regarding copyright ownership.
* Jasig licenses this file to you 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 the following location:
*
* 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 org.jasig.cas.extension.clearpass;
import java.nio.ByteBuffer;
import java.io.UnsupportedEncodingException;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.Arrays;
import java.util.concurrent.ConcurrentHashMap;
import javax.crypto.Cipher;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import javax.validation.constraints.NotNull;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Decorator for a map that will hash the key and encrypt the value.
*
* @author Scott Battaglia
* @since 1.0.6
*/
public final class EncryptedMapDecorator implements Map<String, String> {
private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
private static final String SECRET_KEY_FACTORY_ALGORITHM = "PBKDF2WithHmacSHA1";
private static final String DEFAULT_HASH_ALGORITHM = "SHA-512";
private static final String DEFAULT_ENCRYPTION_ALGORITHM = "AES";
private static final int INTEGER_LEN = 4;
private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd',
'e', 'f'};
private final Logger logger = LoggerFactory.getLogger(getClass());
@NotNull
private final Map<String, String> decoratedMap;
@NotNull
private final MessageDigest messageDigest;
@NotNull
private final byte[] salt;
@NotNull
private final Key key;
@NotNull
private int ivSize;
@NotNull
private final String secretKeyAlgorithm;
private boolean cloneNotSupported;
private ConcurrentHashMap<Object, IvParameterSpec> algorithmParametersHashMap =
new ConcurrentHashMap<Object, IvParameterSpec>();
/**
* Decorates a map using the default algorithm {@link #DEFAULT_HASH_ALGORITHM} and a
* {@link #DEFAULT_ENCRYPTION_ALGORITHM}.
* <p>The salt is randomly constructed when the object is created in memory.
* This constructor is sufficient to decorate
* a cache that only lives in-memory.
*
* @param decoratedMap the map to decorate. CANNOT be NULL.
* @throws Exception if the algorithm cannot be found. Should not happen in this case, or if the key spec is not found
* or if the key is invalid. Check the exception type for more details on the nature of the error.
*/
public EncryptedMapDecorator(final Map<String, String> decoratedMap) throws Exception {
this(decoratedMap, getRandomSalt(8), getRandomSalt(32));
}
/**
* Decorates a map using the default algorithm {@link #DEFAULT_HASH_ALGORITHM}
* and a {@link #DEFAULT_ENCRYPTION_ALGORITHM}.
* <p>Takes a salt and secretKey so that it can work with a distributed cache.
*
* @param decoratedMap the map to decorate. CANNOT be NULL.
* @param salt the salt, as a String. Gets converted to bytes. CANNOT be NULL.
* @param secretKey the secret to use for the key. Gets converted to bytes. CANNOT be NULL.
* @throws Exception if the algorithm cannot be found. Should not happen in this case, or if the key spec is not found
* or if the key is invalid. Check the exception type for more details on the nature of the error.
*/
public EncryptedMapDecorator(final Map<String, String> decoratedMap, final String salt,
final String secretKey) throws Exception {
this(decoratedMap, DEFAULT_HASH_ALGORITHM, salt, DEFAULT_ENCRYPTION_ALGORITHM, secretKey);
}
/**
* Decorates a map using the provided algorithms.
* <p>Takes a salt and secretKey so that it can work with a distributed cache.
*
* @param decoratedMap the map to decorate. CANNOT be NULL.
* @param hashAlgorithm the algorithm to use for hashing. CANNOT BE NULL.
* @param salt the salt, as a String. Gets converted to bytes. CANNOT be NULL.
* @param secretKeyAlgorithm the encryption algorithm. CANNOT BE NULL.
* @param secretKey the secret to use for the key. Gets converted to bytes. CANNOT be NULL.
* @throws Exception if the algorithm cannot be found. Should not happen in this case, or if the key spec is not found
* or if the key is invalid. Check the exception type for more details on the nature of the error.
*/
public EncryptedMapDecorator(final Map<String, String> decoratedMap, final String hashAlgorithm, final String salt,
final String secretKeyAlgorithm, final String secretKey) throws Exception {
this(decoratedMap, hashAlgorithm, salt.getBytes(), secretKeyAlgorithm,
getSecretKey(secretKeyAlgorithm, secretKey, salt));
}
/**
* Decorates a map using the provided algorithms.
* <p>Takes a salt and secretKey so that it can work with a distributed cache.
*
* @param decoratedMap the map to decorate. CANNOT be NULL.
* @param hashAlgorithm the algorithm to use for hashing. CANNOT BE NULL.
* @param salt the salt, as a String. Gets converted to bytes. CANNOT be NULL.
* @param secretKeyAlgorithm the encryption algorithm. CANNOT BE NULL.
* @param secretKey the secret to use. CANNOT be NULL.
* @throws NoSuchAlgorithmException if the algorithm cannot be found. Should not happen in this case.
*/
public EncryptedMapDecorator(final Map<String, String> decoratedMap, final String hashAlgorithm, final byte[] salt,
final String secretKeyAlgorithm, final Key secretKey) throws NoSuchAlgorithmException {
this.decoratedMap = decoratedMap;
this.key = secretKey;
this.salt = salt;
this.secretKeyAlgorithm = secretKeyAlgorithm;
this.messageDigest = MessageDigest.getInstance(hashAlgorithm);
try {
this.ivSize = getIvSize();
} catch (final Exception e) {
throw new RuntimeException(e);
}
}
private static String getRandomSalt(final int size) {
final SecureRandom secureRandom = new SecureRandom();
final byte[] bytes = new byte[size];
secureRandom.nextBytes(bytes);
return getFormattedText(bytes);
}
@Override
public int size() {
return this.decoratedMap.size();
}
@Override
public boolean isEmpty() {
return this.decoratedMap.isEmpty();
}
@Override
public boolean containsKey(final Object key) {
final String hashedKey = constructHashedKey(key.toString());
return this.decoratedMap.containsKey(hashedKey);
}
@Override
public boolean containsValue(final Object value) {
if (!(value instanceof String)) {
return false;
}
final String encryptedValue = encrypt((String) value);
return this.decoratedMap.containsValue(encryptedValue);
}
@Override
public String get(final Object key) {
final String hashedKey = constructHashedKey(key == null ? null : key.toString());
return decrypt(this.decoratedMap.get(hashedKey), hashedKey);
}
@Override
public String put(final String key, final String value) {
final String hashedKey = constructHashedKey(key);
final String hashedValue = encrypt(value, hashedKey);
final String oldValue = this.decoratedMap.put(hashedKey, hashedValue);
return decrypt(oldValue, hashedKey);
}
@Override
public String remove(final Object key) {
final String hashedKey = constructHashedKey(key.toString());
return decrypt(this.decoratedMap.remove(hashedKey), hashedKey);
}
@Override
public void putAll(final Map<? extends String, ? extends String> m) {
for (final Entry<? extends String, ? extends String> entry : m.entrySet()) {
this.put(entry.getKey(), entry.getValue());
}
}
@Override
public void clear() {
this.decoratedMap.clear();
}
@Override
public Set<String> keySet() {
throw new UnsupportedOperationException();
}
@Override
public Collection<String> values() {
throw new UnsupportedOperationException();
}
@Override
public Set<Entry<String, String>> entrySet() {
throw new UnsupportedOperationException();
}
protected String constructHashedKey(final String key) {
if (key == null) {
return null;
}
final MessageDigest messageDigest = getMessageDigest();
messageDigest.update(this.salt);
messageDigest.update(key.toLowerCase().getBytes());
final String hash = getFormattedText(messageDigest.digest());
logger.debug(String.format("Generated hash of value [%s] for key [%s].", hash, key));
return hash;
}
protected String decrypt(final String value, final String hashedKey) {
if (value == null) {
return null;
}
try {
final Cipher cipher = getCipherObject();
final byte[] ivCiphertext = decode(value.getBytes());
final int ivSize = byte2int(Arrays.copyOfRange(ivCiphertext, 0, INTEGER_LEN));
final byte[] ivValue = Arrays.copyOfRange(ivCiphertext, INTEGER_LEN, (INTEGER_LEN + ivSize));
final byte[] ciphertext = Arrays.copyOfRange(ivCiphertext, INTEGER_LEN + ivSize, ivCiphertext.length);
final IvParameterSpec ivSpec = new IvParameterSpec(ivValue);
cipher.init(Cipher.DECRYPT_MODE, this.key, ivSpec);
final byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext);
} catch (final Exception e) {
throw new RuntimeException(e);
}
}
private static int getIvSize() throws NoSuchAlgorithmException, NoSuchPaddingException {
return Cipher.getInstance(CIPHER_ALGORITHM).getBlockSize();
}
private static byte[] generateIV(final int size) {
final SecureRandom srand = new SecureRandom();
final byte[] ivValue = new byte[size];
srand.nextBytes(ivValue);
return ivValue;
}
private static byte[] encode(final byte[] bytes) {
return new Base64().encode(bytes);
}
private static byte[] decode(final byte[] bytes) {
return new Base64().decode(bytes);
}
protected String encrypt(final String value) {
return encrypt(value, null);
}
protected String encrypt(final String value, final String hashedKey) {
if (value == null) {
return null;
}
try {
final Cipher cipher = getCipherObject();
final byte[] ivValue = generateIV(this.ivSize);
final IvParameterSpec ivSpec = new IvParameterSpec(ivValue);
cipher.init(Cipher.ENCRYPT_MODE, this.key, ivSpec);
final byte[] ciphertext = cipher.doFinal(value.getBytes());
final byte[] ivCiphertext = new byte[INTEGER_LEN + this.ivSize + ciphertext.length];
System.arraycopy(int2byte(this.ivSize), 0, ivCiphertext, 0, INTEGER_LEN);
System.arraycopy(ivValue, 0, ivCiphertext, INTEGER_LEN, this.ivSize);
System.arraycopy(ciphertext, 0, ivCiphertext, INTEGER_LEN + this.ivSize, ciphertext.length);
return new String(encode(ivCiphertext));
} catch(final Exception e) {
throw new RuntimeException(e);
}
}
protected static byte[] int2byte(final int i) throws UnsupportedEncodingException {
return ByteBuffer.allocate(4).putInt(i).array();
}
protected static int byte2int(final byte[] bytes) throws UnsupportedEncodingException {
return ByteBuffer.wrap(bytes).getInt();
}
protected static String byte2char(final byte[] bytes) throws UnsupportedEncodingException {
return new String(bytes, "UTF-8");
}
protected static byte[] char2byte(final String chars) throws UnsupportedEncodingException {
return chars.getBytes("UTF-8");
}
/**
* Tries to clone the {@link MessageDigest} that was created during construction. If the clone fails
* that is remembered and from that point on new {@link MessageDigest} instances will be created on
* every call.
* <p>
* Adopted from the Spring EhCache Annotations project.
*
* @return Generates a {@link MessageDigest} to use
*/
protected MessageDigest getMessageDigest() {
if (this.cloneNotSupported) {
final String algorithm = this.messageDigest.getAlgorithm();
try {
return MessageDigest.getInstance(algorithm);
} catch (final NoSuchAlgorithmException e) {
throw new IllegalStateException("MessageDigest algorithm '" + algorithm + "' was supported when "
+ this.getClass().getSimpleName()
+ " was created but is not now. This should not be possible.", e);
}
}
try {
return (MessageDigest) this.messageDigest.clone();
} catch (final CloneNotSupportedException e) {
this.cloneNotSupported = true;
final String msg = String.format("Could not clone MessageDigest using algorithm '%s'. "
+ "MessageDigest.getInstance will be used from now on which will be much more expensive.",
this.messageDigest.getAlgorithm());
logger.warn(msg, e);
return this.getMessageDigest();
}
}
/**
* Takes the raw bytes from the digest and formats them correct.
*
* @param bytes the raw bytes from the digest.
* @return the formatted bytes.
*/
private static String getFormattedText(final byte[] bytes) {
final StringBuilder buf = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
buf.append(HEX_DIGITS[b >> 4 & 0x0f]);
buf.append(HEX_DIGITS[b & 0x0f]);
}
return buf.toString();
}
private Cipher getCipherObject() throws NoSuchAlgorithmException, NoSuchPaddingException {
return Cipher.getInstance(CIPHER_ALGORITHM);
}
private static Key getSecretKey(final String secretKeyAlgorithm, final String secretKey,
final String salt) throws Exception {
SecretKeyFactory factory = SecretKeyFactory.getInstance(SECRET_KEY_FACTORY_ALGORITHM);
KeySpec spec = new PBEKeySpec(secretKey.toCharArray(), char2byte(salt), 65536, 128);
SecretKey tmp = factory.generateSecret(spec);
SecretKey secret = new SecretKeySpec(tmp.getEncoded(), secretKeyAlgorithm);
return secret;
}
public String getSecretKeyAlgorithm() {
return secretKeyAlgorithm;
}
}