/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF 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
*
* 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.apache.jackrabbit.core.security.user;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import org.apache.jackrabbit.util.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Utility to generate and compare password hashes.
*/
public class PasswordUtility {
private static final Logger log = LoggerFactory.getLogger(PasswordUtility.class);
private static final char DELIMITER = '-';
private static final int NO_ITERATIONS = 1;
private static final String ENCODING = "UTF-8";
public static final String DEFAULT_ALGORITHM = "SHA-256";
public static final int DEFAULT_SALT_SIZE = 8;
public static final int DEFAULT_ITERATIONS = 1000;
/**
* Avoid instantiation
*/
private PasswordUtility() {}
/**
* Generates a hash of the specified password with the default values
* for algorithm, salt-size and number of iterations.
*
* @param password The password to be hashed.
* @return The password hash.
* @throws NoSuchAlgorithmException If {@link #DEFAULT_ALGORITHM} is not supported.
* @throws UnsupportedEncodingException If utf-8 is not supported.
*/
public static String buildPasswordHash(String password) throws NoSuchAlgorithmException, UnsupportedEncodingException {
return buildPasswordHash(password, DEFAULT_ALGORITHM, DEFAULT_SALT_SIZE, DEFAULT_ITERATIONS);
}
/**
* Generates a hash of the specified password using the specified algorithm,
* salt size and number of iterations into account.
*
* @param password The password to be hashed.
* @param algorithm The desired hash algorithm.
* @param saltSize The desired salt size. If the specified integer is lower
* that {@link #DEFAULT_SALT_SIZE} the default is used.
* @param iterations The desired number of iterations. If the specified
* integer is lower than 1 the {@link #DEFAULT_ITERATIONS default} value is used.
* @return The password hash.
* @throws NoSuchAlgorithmException If the specified algorithm is not supported.
* @throws UnsupportedEncodingException If utf-8 is not supported.
*/
public static String buildPasswordHash(String password, String algorithm,
int saltSize, int iterations) throws NoSuchAlgorithmException, UnsupportedEncodingException {
if (password == null) {
throw new IllegalArgumentException("Password may not be null.");
}
if (iterations < NO_ITERATIONS) {
iterations = DEFAULT_ITERATIONS;
}
if (saltSize < DEFAULT_SALT_SIZE) {
saltSize = DEFAULT_SALT_SIZE;
}
String salt = generateSalt(saltSize);
String alg = (algorithm == null) ? DEFAULT_ALGORITHM : algorithm;
return generateHash(password, alg, salt, iterations);
}
/**
* Returns {@code true} if the specified string doesn't start with a
* valid algorithm name in curly brackets.
*
* @param password The string to be tested.
* @return {@code true} if the specified string doesn't start with a
* valid algorithm name in curly brackets.
*/
public static boolean isPlainTextPassword(String password) {
return extractAlgorithm(password) == null;
}
/**
* Returns {@code true} if hash of the specified {@code password} equals the
* given hashed password.
*
* @param hashedPassword Password hash.
* @param password The password to compare.
* @return If the hash of the specified {@code password} equals the given
* {@code hashedPassword} string.
*/
public static boolean isSame(String hashedPassword, String password) {
try {
String algorithm = extractAlgorithm(hashedPassword);
if (algorithm != null) {
int startPos = algorithm.length()+2;
String salt = extractSalt(hashedPassword, startPos);
int iterations = NO_ITERATIONS;
if (salt != null) {
startPos += salt.length()+1;
iterations = extractIterations(hashedPassword, startPos);
}
String hash = generateHash(password, algorithm, salt, iterations);
return compareSecure(hashedPassword, hash);
} // hashedPassword is plaintext -> return false
} catch (NoSuchAlgorithmException e) {
log.warn(e.getMessage());
} catch (UnsupportedEncodingException e) {
log.warn(e.getMessage());
}
return false;
}
/**
* Extract the algorithm from the given crypted password string. Returns the
* algorithm or {@code null} if the given string doesn't have a
* leading {@code algorithm} such as created by {@code buildPasswordHash}
* or if the extracted string doesn't represent an available algorithm.
*
* @param hashedPwd The password hash.
* @return The algorithm or {@code null} if the given string doesn't have a
* leading {@code algorithm} such as created by {@code buildPasswordHash}
* or if the extracted string isn't a supported algorithm.
*/
public static String extractAlgorithm(String hashedPwd) {
if (hashedPwd != null && hashedPwd.length() > 0) {
int end = hashedPwd.indexOf('}');
if (hashedPwd.charAt(0) == '{' && end > 0 && end < hashedPwd.length()-1) {
String algorithm = hashedPwd.substring(1, end);
try {
MessageDigest.getInstance(algorithm);
return algorithm;
} catch (NoSuchAlgorithmException e) {
log.debug("Invalid algorithm detected " + algorithm);
}
}
}
// not starting with {} or invalid algorithm
return null;
}
//------------------------------------------------------------< private >---
private static String generateHash(String pwd, String algorithm, String salt, int iterations) throws NoSuchAlgorithmException, UnsupportedEncodingException {
StringBuilder passwordHash = new StringBuilder();
passwordHash.append('{').append(algorithm).append('}');
if (salt != null && salt.length() > 0) {
StringBuilder data = new StringBuilder();
data.append(salt).append(pwd);
passwordHash.append(salt).append(DELIMITER);
if (iterations > NO_ITERATIONS) {
passwordHash.append(iterations).append(DELIMITER);
}
passwordHash.append(generateDigest(data.toString(), algorithm, iterations));
} else {
// backwards compatible to jr 2.0: no salt, no iterations
passwordHash.append(Text.digest(algorithm, pwd.getBytes(ENCODING)));
}
return passwordHash.toString();
}
private static String generateSalt(int saltSize) {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[saltSize];
random.nextBytes(salt);
StringBuilder res = new StringBuilder(salt.length * 2);
for (byte b : salt) {
res.append(Text.hexTable[(b >> 4) & 15]);
res.append(Text.hexTable[b & 15]);
}
return res.toString();
}
private static String generateDigest(String data, String algorithm, int iterations) throws UnsupportedEncodingException, NoSuchAlgorithmException {
byte[] bytes = data.getBytes(ENCODING);
MessageDigest md = MessageDigest.getInstance(algorithm);
for (int i = 0; i < iterations; i++) {
md.reset();
bytes = md.digest(bytes);
}
StringBuilder res = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
res.append(Text.hexTable[(b >> 4) & 15]);
res.append(Text.hexTable[b & 15]);
}
return res.toString();
}
private static String extractSalt(String hashedPwd, int start) {
int end = hashedPwd.indexOf(DELIMITER, start);
if (end > -1) {
return hashedPwd.substring(start, end);
}
// no salt
return null;
}
private static int extractIterations(String hashedPwd, int start) {
int end = hashedPwd.indexOf(DELIMITER, start);
if (end > -1) {
String str = hashedPwd.substring(start, end);
try {
return Integer.parseInt(str);
} catch (NumberFormatException e) {
log.debug("Expected number of iterations. Found: " + str);
}
}
// no extra iterations
return NO_ITERATIONS;
}
/**
* Compare two strings. The comparison is constant time: it will always loop
* over all characters and doesn't use conditional operations in the loop to
* make sure an attacker can not use a timing attack.
*
* @param a
* @param b
* @return true if both parameters contain the same data.
*/
private static boolean compareSecure(String a, String b) {
if ((a == null) || (b == null)) {
return (a == null) && (b == null);
}
int len = a.length();
if (len != b.length()) {
return false;
}
if (len == 0) {
return true;
}
// don't use conditional operations inside the loop
int bits = 0;
for (int i = 0; i < len; i++) {
// this will never reset any bits
bits |= a.charAt(i) ^ b.charAt(i);
}
return bits == 0;
}
}