blob: b7b16bd42ab1368eb16228189fdd0b216e6a310f [file] [log] [blame]
/*
* 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.oak.spi.security.user.util;
import java.io.UnsupportedEncodingException;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
import org.apache.jackrabbit.util.Text;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.google.common.base.Preconditions.checkNotNull;
/**
* Utility to generate and compare password hashes.
*/
public final class PasswordUtil {
private static final Logger log = LoggerFactory.getLogger(PasswordUtil.class);
private static final char DELIMITER = '-';
private static final int NO_ITERATIONS = 1;
private static final String ENCODING = "UTF-8";
/**
* @since OAK 1.0
*/
static final String PBKDF2_PREFIX = "PBKDF2";
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 PasswordUtil() {}
/**
* 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(@NotNull 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. If the algorith is
* {@code null} the {@link #DEFAULT_ALGORITHM} will be used.
* @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(@NotNull String password,
@Nullable String algorithm,
int saltSize, int iterations) throws NoSuchAlgorithmException, UnsupportedEncodingException {
checkNotNull(password);
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);
}
/**
* Same as {@link #buildPasswordHash(String, String, int, int)} but retrieving
* the parameters for hash generation from the specified configuration.
*
* @param password The password to be hashed.
* @param config The configuration defining the details of the hash generation.
* @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(@NotNull String password,
@NotNull ConfigurationParameters config) throws NoSuchAlgorithmException, UnsupportedEncodingException {
checkNotNull(config);
String algorithm = config.getConfigValue(UserConstants.PARAM_PASSWORD_HASH_ALGORITHM, DEFAULT_ALGORITHM);
int iterations = config.getConfigValue(UserConstants.PARAM_PASSWORD_HASH_ITERATIONS, DEFAULT_ITERATIONS);
int saltSize = config.getConfigValue(UserConstants.PARAM_PASSWORD_SALT_SIZE, DEFAULT_SALT_SIZE);
return buildPasswordHash(password, algorithm, saltSize, 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(@Nullable 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 created from the specified {@code password} equals
* the given {@code hashedPassword} string.
*/
public static boolean isSame(@Nullable String hashedPassword, @NotNull char[] password) {
return isSame(hashedPassword, String.valueOf(password));
}
/**
* 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 created from the specified {@code password} equals
* the given {@code hashedPassword} string.
*/
public static boolean isSame(@Nullable String hashedPassword, @NotNull String password) {
if (hashedPassword == null || password == null) {
return false;
}
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 | UnsupportedEncodingException e) {
log.warn(e.getMessage());
}
return false;
}
//------------------------------------------------------------< private >---
/**
* 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(@NotNull String a, @NotNull String b) {
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;
}
@NotNull
private static String generateHash(@NotNull String pwd, @NotNull String algorithm,
@Nullable String salt, int iterations) throws NoSuchAlgorithmException, UnsupportedEncodingException {
StringBuilder passwordHash = new StringBuilder();
passwordHash.append('{').append(algorithm).append('}');
if (salt != null && !salt.isEmpty()) {
StringBuilder data = new StringBuilder();
data.append(salt).append(pwd);
passwordHash.append(salt).append(DELIMITER);
if (iterations > NO_ITERATIONS) {
passwordHash.append(iterations).append(DELIMITER);
}
String digest;
if (algorithm.startsWith(PBKDF2_PREFIX)) {
digest = generatePBKDF2(pwd, salt, algorithm, iterations, 128);
} else {
digest = generateDigest(data.toString(), algorithm, iterations);
}
passwordHash.append(digest);
} else {
// backwards compatible to jr 2.0: no salt, no iterations
passwordHash.append(Text.digest(algorithm, pwd.getBytes(ENCODING)));
}
return passwordHash.toString();
}
@NotNull
private static String generateSalt(int saltSize) {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[saltSize];
random.nextBytes(salt);
return convertBytesToHex(salt);
}
/**
* Convert a byte array to a hex encoded string.
*
* @param bytes the byte array
* @return the hex encoded string
*/
@NotNull
private static String convertBytesToHex(byte[] 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();
}
/**
* Convert a hex encoded string to a byte array.
*
* @param s the hex encoded string
* @return the byte array
*/
@NotNull
private static byte[] convertHexToBytes(String s) {
int len = s.length();
if (len % 2 != 0) {
throw new IllegalArgumentException("Not a hex encoded byte array: " + s);
}
byte[] bytes = new byte[len / 2];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (
(Character.digit(s.charAt(i + i), 16) << 4) +
Character.digit(s.charAt(i + i + 1), 16));
}
return bytes;
}
@NotNull
private static String generatePBKDF2(@NotNull String pwd, @NotNull String salt,
@NotNull String algorithm, int iterations, int keyLength) throws NoSuchAlgorithmException {
// for example PBKDF2WithHmacSHA1
SecretKeyFactory factory = SecretKeyFactory.getInstance(algorithm);
byte[] saltBytes = convertHexToBytes(salt);
KeySpec keyspec = new PBEKeySpec(pwd.toCharArray(), saltBytes, iterations, keyLength);
try {
Key key = factory.generateSecret(keyspec);
byte[] bytes = key.getEncoded();
return convertBytesToHex(bytes);
} catch (InvalidKeySpecException e) {
throw new NoSuchAlgorithmException(algorithm, e);
}
}
@NotNull
private static String generateDigest(@NotNull String data, @NotNull 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);
}
return convertBytesToHex(bytes);
}
/**
* 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.
*/
@Nullable
private static String extractAlgorithm(@Nullable String hashedPwd) {
if (hashedPwd != null && !hashedPwd.isEmpty()) {
int end = hashedPwd.indexOf('}');
if (hashedPwd.charAt(0) == '{' && end > 0 && end < hashedPwd.length()-1) {
String algorithm = hashedPwd.substring(1, end);
try {
if (algorithm.startsWith(PBKDF2_PREFIX)) {
SecretKeyFactory.getInstance(algorithm);
} else {
MessageDigest.getInstance(algorithm);
}
return algorithm;
} catch (NoSuchAlgorithmException e) {
log.debug("Invalid algorithm detected " + algorithm, e);
}
}
}
// not starting with {} or invalid algorithm
return null;
}
@Nullable
private static String extractSalt(@Nullable String hashedPwd, int start) {
if (hashedPwd != null) {
int end = hashedPwd.indexOf(DELIMITER, start);
if (end > -1) {
return hashedPwd.substring(start, end);
}
}
// no salt
return null;
}
private static int extractIterations(@Nullable String hashedPwd, int start) {
if (hashedPwd != null) {
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, e);
}
}
}
// no extra iterations
return NO_ITERATIONS;
}
}