blob: 64a44950c7a272f70ff9723006d6ed7b3c57f5ea [file] [log] [blame]
// Copyright (C) 2017 The Android Open Source Project
//
// 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.
package com.google.gerrit.server.account;
import static com.google.common.base.Preconditions.checkState;
import com.google.common.base.Splitter;
import com.google.common.io.BaseEncoding;
import com.google.common.primitives.Ints;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.List;
import org.bouncycastle.crypto.generators.BCrypt;
import org.bouncycastle.util.Arrays;
/**
* Holds logic for salted, hashed passwords. It uses BCrypt from BouncyCastle, which truncates
* passwords at 72 bytes.
*/
public class HashedPassword {
private static final String ALGORITHM_PREFIX = "bcrypt:";
private static final SecureRandom secureRandom = new SecureRandom();
private static final BaseEncoding codec = BaseEncoding.base64();
// bcrypt uses 2^cost rounds. Since we use a generated random password, no need
// for a high cost.
private static final int DEFAULT_COST = 4;
public static class DecoderException extends Exception {
private static final long serialVersionUID = 1L;
public DecoderException(String message) {
super(message);
}
}
/**
* decodes a hashed password encoded with {@link #encode}.
*
* @throws DecoderException if input is malformed.
*/
public static HashedPassword decode(String encoded) throws DecoderException {
if (!encoded.startsWith(ALGORITHM_PREFIX)) {
throw new DecoderException("unrecognized algorithm");
}
List<String> fields = Splitter.on(':').splitToList(encoded);
if (fields.size() != 4) {
throw new DecoderException("want 4 fields");
}
Integer cost = Ints.tryParse(fields.get(1));
if (cost == null) {
throw new DecoderException("cost parse failed");
}
if (!(cost >= 4 && cost < 32)) {
throw new DecoderException("cost should be 4..31 inclusive, got " + cost);
}
byte[] salt = codec.decode(fields.get(2));
if (salt.length != 16) {
throw new DecoderException("salt should be 16 bytes, got " + salt.length);
}
return new HashedPassword(codec.decode(fields.get(3)), salt, cost);
}
private static byte[] hashPassword(String password, byte[] salt, int cost) {
byte[] pwBytes = password.getBytes(StandardCharsets.UTF_8);
return BCrypt.generate(pwBytes, salt, cost);
}
public static HashedPassword fromPassword(String password) {
byte[] salt = newSalt();
return new HashedPassword(hashPassword(password, salt, DEFAULT_COST), salt, DEFAULT_COST);
}
private static byte[] newSalt() {
byte[] bytes = new byte[16];
secureRandom.nextBytes(bytes);
return bytes;
}
private byte[] salt;
private byte[] hashed;
private int cost;
private HashedPassword(byte[] hashed, byte[] salt, int cost) {
this.salt = salt;
this.hashed = hashed;
this.cost = cost;
checkState(cost >= 4 && cost < 32);
// salt must be 128 bit.
checkState(salt.length == 16);
}
/**
* Serialize the hashed password and its parameters for persistent storage.
*
* @return one-line string encoding the hash and salt.
*/
public String encode() {
return ALGORITHM_PREFIX + cost + ":" + codec.encode(salt) + ":" + codec.encode(hashed);
}
public boolean checkPassword(String password) {
// Constant-time comparison, because we're paranoid.
return Arrays.areEqual(hashPassword(password, salt, cost), hashed);
}
}