/*
* 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.
*
* Contributions from 2013-2017 where performed either by US government
* employees, or under US Veterans Health Administration contracts.
*
* US Veterans Health Administration contributions by government employees
* are work of the U.S. Government and are not subject to copyright
* protection in the United States. Portions contributed by government
* employees are USGovWork (17USC ยง105). Not subject to copyright.
*
* Contribution by contractors to the US Veterans Health Administration
* during this period are contractually contributed under the
* Apache License, Version 2.0.
*
* See: https://www.usa.gov/government-works
*
* Contributions prior to 2013:
*
* Copyright (C) International Health Terminology Standards Development Organisation.
* Licensed under the Apache License, Version 2.0.
*
*/
package sh.isaac.api.util;
//~--- JDK imports ------------------------------------------------------------
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
//~--- non-JDK imports --------------------------------------------------------
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
//~--- classes ----------------------------------------------------------------
/**
* {@link PasswordHasher}
*
* A safe, modern way to 1-way hash user passwords.
* Adapted and enhanced from http://stackoverflow.com/a/11038230/2163960
*
* Later, added the ability to encrypt and decrypt arbitrary data - using many of the same
* techniques.
*
* @author <a href="mailto:daniel.armbrust.list@gmail.com">Dan Armbrust</a>
*/
public class PasswordHasher {
/** The Constant log_. */
private static final Logger log = LoggerFactory.getLogger(PasswordHasher.class);
// The higher the number of iterations the more expensive computing the hash is for us
/** The Constant iterations. */
// and also for a brute force attack.
private static final int iterations = 10 * 1024;
/** The Constant saltLen. */
private static final int saltLen = 32;
/** The Constant desiredKeyLen. */
private static final int desiredKeyLen = 256;
/** The Constant keyFactoryAlgorithm. */
private static final String keyFactoryAlgorithm = "PBKDF2WithHmacSHA1";
/** The Constant cipherAlgorithm. */
private static final String cipherAlgorithm = "PBEWithSHA1AndDESede";
// private static final Random random = new Random(); //Note, it would be more secure to use SecureRandom... but the entropy issues on Linux are a nasty issue
// and it results in SecureRandom.getInstance(...).generateSeed(...) blocking for long periods of time. A regular random is certainly good enough
/** The Constant secureRandom. */
// for our encryption purposes.
private static final SecureRandom secureRandom = new SecureRandom();
//~--- methods -------------------------------------------------------------
/**
* Checks whether given plaintext password corresponds to a stored salted hash of the password.
*
* @param password the password
* @param stored the stored
* @return true, if successful
* @throws Exception the exception
*/
public static boolean check(String password, String stored)
throws Exception {
final String[] saltAndPass = stored.split("\\$\\$\\$");
if (saltAndPass.length != 2) {
return false;
}
if ((password == null) || (password.length() == 0)) {
return false;
}
final String hashOfInput = hash(password, Base64.getUrlDecoder()
.decode(saltAndPass[0]));
return hashOfInput.equals(saltAndPass[1]);
}
/**
* Decrypt.
*
* @param password the password
* @param encryptedData the encrypted data
* @return the byte[]
* @throws Exception the exception
*/
public static byte[] decrypt(String password, String encryptedData)
throws Exception {
final long startTime = System.currentTimeMillis();
final String[] saltAndPass = encryptedData.split("\\$\\$\\$");
if (saltAndPass.length != 2) {
throw new Exception("Invalid encrypted data, can't find salt");
}
final byte[] result = decrypt(password, Base64.getUrlDecoder()
.decode(saltAndPass[0]), saltAndPass[1]);
log.debug("Decrypt Time {} ms", System.currentTimeMillis() - startTime);
return result;
}
/**
* Decrypt to string.
*
* @param password the password
* @param encryptedData the encrypted data
* @return the string
* @throws Exception the exception
*/
public static String decryptToString(String password, String encryptedData)
throws Exception {
return new String(decrypt(password, encryptedData), "UTF-8");
}
/**
* Encrypt.
*
* @param password the password
* @param data the data
* @return the string
* @throws Exception the exception
*/
public static String encrypt(String password, byte[] data)
throws Exception {
final long startTime = System.currentTimeMillis();
final byte[] salt = new byte[saltLen];
secureRandom.nextBytes(salt);
// store the salt with the password
final String result = Base64.getUrlEncoder()
.encodeToString(salt) + "$$$" + encrypt(password, salt, data);
log.debug("Encrypt Time {} ms", System.currentTimeMillis() - startTime);
return result;
}
/**
* Encrypt.
*
* @param password the password
* @param data the data
* @return the string
* @throws Exception the exception
*/
public static String encrypt(String password, String data)
throws Exception {
return encrypt(password, data.getBytes("UTF-8"));
}
/**
* Computes a salted PBKDF2 hash of given plaintext password with the provided salt
* Empty passwords are not supported.
*
* @param password the password
* @param salt the salt
* @return a Base64 encoded hash
* @throws Exception the exception
*/
public static String hash(String password, byte[] salt)
throws Exception {
return hash(password, salt, iterations, desiredKeyLen);
}
/**
* Computes a salted PBKDF2 hash of given plaintext password with the provided salt
* Empty passwords are not supported.
*
* @param password the password
* @param salt the salt
* @param iterationCount the iteration count
* @param keyLength the key length
* @return a URL Safe Base64 encoded hash
* @throws Exception the exception
*/
public static String hash(String password, byte[] salt, int iterationCount, int keyLength)
throws Exception {
final long startTime = System.currentTimeMillis();
if ((password == null) || (password.length() == 0)) {
throw new IllegalArgumentException("Empty passwords are not supported.");
}
final SecretKeyFactory f = SecretKeyFactory.getInstance(keyFactoryAlgorithm);
final SecretKey key = f.generateSecret(new PBEKeySpec(password.toCharArray(), salt, iterationCount, keyLength));
final String result = Base64.getUrlEncoder()
.encodeToString(key.getEncoded());
log.debug("Password compute time: {} ms", System.currentTimeMillis() - startTime);
return result;
}
/**
* Decrypt.
*
* @param password the password
* @param salt the salt
* @param data the data
* @return the byte[]
* @throws Exception the exception
*/
private static byte[] decrypt(String password, byte[] salt, String data)
throws Exception {
final SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(keyFactoryAlgorithm);
final SecretKey key = keyFactory.generateSecret(new PBEKeySpec(password.toCharArray(),
salt,
iterations,
desiredKeyLen));
final Cipher pbeCipher = Cipher.getInstance(cipherAlgorithm);
pbeCipher.init(Cipher.DECRYPT_MODE, key, new PBEParameterSpec(salt, iterations));
byte[] decrypted;
try {
decrypted = pbeCipher.doFinal(Base64.getUrlDecoder()
.decode(data));
} catch (final Exception e) {
throw new Exception("Invalid decryption password");
}
if (decrypted.length >= 40) {
Locale.setDefault(Locale.US); // ensure .equals below is using same Locale. (Fortify)
// The last 40 bytes should be the SHA1 Sum
final String checkSum = new String(Arrays.copyOfRange(decrypted, decrypted.length - 40, decrypted.length));
final byte[] userData = Arrays.copyOf(decrypted, decrypted.length - 40);
final String computed = ChecksumGenerator.calculateChecksum("SHA1", userData);
if (!checkSum.equals(computed)) {
throw new Exception("Invalid decryption password, or truncated data");
} else {
return userData;
}
} else {
throw new Exception("Truncated data");
}
}
/**
* Encrypt.
*
* @param password the password
* @param salt the salt
* @param data the data
* @return the string
* @throws Exception the exception
*/
private static String encrypt(String password, byte[] salt, byte[] data)
throws Exception {
final SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(keyFactoryAlgorithm);
final SecretKey key = keyFactory.generateSecret(new PBEKeySpec(password.toCharArray(),
salt,
iterations,
desiredKeyLen));
final Cipher pbeCipher = Cipher.getInstance(cipherAlgorithm);
pbeCipher.init(Cipher.ENCRYPT_MODE, key, new PBEParameterSpec(salt, iterations));
// attach a sha1 checksum to the end of the data, so we know if we decrypted it properly.
final byte[] dataCheckSum = ChecksumGenerator.calculateChecksum("SHA1", data)
.getBytes();
final ByteBuffer temp = ByteBuffer.allocate(data.length + dataCheckSum.length);
temp.put(data);
temp.put(dataCheckSum);
return Base64.getUrlEncoder()
.encodeToString(pbeCipher.doFinal(temp.array()));
}
//~--- get methods ---------------------------------------------------------
/**
* Computes a salted PBKDF2 hash of given plaintext password suitable for storing in a database.
* Empty passwords are not supported.
*
* @param password the password
* @return the salted hash
* @throws Exception the exception
*/
public static String getSaltedHash(String password)
throws Exception {
final long startTime = System.currentTimeMillis();
final byte[] salt = new byte[saltLen];
secureRandom.nextBytes(salt);
// store the salt with the password
final String result = Base64.getUrlEncoder()
.encodeToString(salt) + "$$$" + hash(password, salt);
log.debug("Compute Salted Hash time {} ms", System.currentTimeMillis() - startTime);
return result;
}
}