UpdatingBCrypt.java
/*
* Copyright 2018 Gregory Graham.
*
* Commercial licenses are available, please contact info@gregs.co.nz for details.
*
* This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
* To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/
* or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
*
* You are free to:
* Share - copy and redistribute the material in any medium or format
* Adapt - remix, transform, and build upon the material
*
* The licensor cannot revoke these freedoms as long as you follow the license terms.
* Under the following terms:
*
* Attribution -
* You must give appropriate credit, provide a link to the license, and indicate if changes were made.
* You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
* NonCommercial -
* You may not use the material for commercial purposes.
* ShareAlike -
* If you remix, transform, or build upon the material,
* you must distribute your contributions under the same license as the original.
* No additional restrictions -
* You may not apply legal terms or technological measures that legally restrict others from doing anything the
* license permits.
*
* Check the Creative Commons website for any details, legalese, and updates.
*/
package nz.co.gregs.dbvolution.utility.encryption;
import com.google.common.base.Stopwatch;
import java.util.concurrent.TimeUnit;
import nz.co.gregs.dbvolution.exceptions.IncorrectPasswordException;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.mindrot.jbcrypt.BCrypt;
/**
*
* @author gregorygraham
*/
public class UpdatingBCrypt {
static final Log LOG = LogFactory.getLog(UpdatingBCrypt.class);
private final int logRounds;
public static final int DEFAULT_ROUNDS = 5;
public static final long MINIMUM_EFFORT = 100l;
public static final long MAXIMUM_EFFORT = 1000l;
public UpdatingBCrypt() {
this(DEFAULT_ROUNDS);
}
/**
* Minimum of 4 rounds.
*
* <p>
* There is a minimum number of rounds.</p>
*
* @param logRounds the number of rounds of encoding to perform
*/
public UpdatingBCrypt(int logRounds) {
this.logRounds = logRounds >= 4 ? logRounds : DEFAULT_ROUNDS;
}
public String hashPassword(String password) {
return hashPassword(password, logRounds);
}
private String hashPassword(String password, int logRounds) {
return BCrypt.hashpw(password, BCrypt.gensalt(logRounds));
}
public static boolean checkPassword(String password, String hash) {
return BCrypt.checkpw(password, hash);
}
/**
* Checks the password matches the hash and automatically generates a new one
* if the processor speed warrants it.
*
* <p>
* If the password does not match the hash an IncorrectPasswordException is
* thrown.</p>
*
* <p>
* If the password successfully matches to the hash, a quick hash is made to
* test the processor speed. If the quick hash is faster than the minimum work
* requirement, then a hash stronger than the original is made and returned
* from the method.</p>
*
* <p>
* Alternatively if the "quick" hash takes more than a second, the work
* requirement is reduced and a new hash is returned.</p>
*
* @param password the password supplied during authentication
* @param hash the password hash stored during user creation or password
* update
* @return the original hash or a more appropriate hash for your CPU.
* @throws IncorrectPasswordException if the password and hash cannot be
* reconciled
*/
public String checkPasswordAndCreateSecureHash(String password, String hash) throws IncorrectPasswordException {
if (looksLikeABCryptHash(hash)) {
if (BCrypt.checkpw(password, hash)) {
int rounds = getRounds(hash);
int minRounds = Math.max(DEFAULT_ROUNDS, rounds - 2);
Stopwatch timer = Stopwatch.createStarted();
String newHash = hashPassword(password, minRounds);
timer.stop();
long elapsed = timer.elapsed(TimeUnit.MILLISECONDS);
if (elapsed < MINIMUM_EFFORT) {
int newRounds = rounds + 1;
LOG.debug("Updating password from " + rounds + " rounds to " + newRounds);
newHash = hashPassword(password, newRounds);
return newHash;
} else if (elapsed >= MAXIMUM_EFFORT) {
LOG.debug("Updating password from " + rounds + " rounds to " + minRounds);
return newHash;
}
} else {
throw new IncorrectPasswordException(hash);
}
} else {
if (hash.equals(password)) {
return hashPassword(password);
} else {
throw new IncorrectPasswordException(hash);
}
}
return hash;
}
/*
* Copy pasted from BCrypt internals :(. Ideally a method
* to exports parts would be public. We only care about rounds
* currently.
*/
private int getRounds(String hash) {
char minor;
int off = 0;
if (hash.charAt(0) != '$' || hash.charAt(1) != '2') {
throw new IllegalArgumentException("Invalid salt version");
}
if (hash.charAt(2) == '$') {
off = 3;
} else {
minor = hash.charAt(2);
if (minor != 'a' || hash.charAt(3) != '$') {
throw new IllegalArgumentException("Invalid salt revision");
}
off = 4;
}
// Extract number of rounds
if (hash.charAt(off + 2) > '$') {
throw new IllegalArgumentException("Missing salt rounds");
}
return Integer.parseInt(hash.substring(off, off + 2));
}
public static boolean looksLikeABCryptHash(String maybeHash) {
return maybeHash != null && maybeHash.matches("\\$..\\$..\\$.*");
}
}