| // 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 String ALGORITHM_PREFIX_0 = "bcrypt0:"; |
| 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) && !encoded.startsWith(ALGORITHM_PREFIX_0)) { |
| 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, encoded.startsWith(ALGORITHM_PREFIX_0)); |
| } |
| |
| private static byte[] hashPassword( |
| String password, byte[] salt, int cost, boolean nullTerminate) { |
| byte[] pwBytes = password.getBytes(StandardCharsets.UTF_8); |
| if (nullTerminate && !password.endsWith("\0")) { |
| pwBytes = Arrays.append(pwBytes, (byte) 0); |
| } |
| return BCrypt.generate(pwBytes, salt, cost); |
| } |
| |
| public static HashedPassword fromPassword(String password) { |
| byte[] salt = newSalt(); |
| |
| return new HashedPassword( |
| hashPassword(password, salt, DEFAULT_COST, true), salt, DEFAULT_COST, true); |
| } |
| |
| private static byte[] newSalt() { |
| byte[] bytes = new byte[16]; |
| secureRandom.nextBytes(bytes); |
| return bytes; |
| } |
| |
| private byte[] salt; |
| private byte[] hashed; |
| private int cost; |
| // Raw bcrypt repeats the password, so "ABC" works for "ABCABC" too. To prevent this, add |
| // the terminating null char to the password. |
| boolean nullTerminate; |
| |
| private HashedPassword(byte[] hashed, byte[] salt, int cost, boolean nullTerminate) { |
| this.salt = salt; |
| this.hashed = hashed; |
| this.cost = cost; |
| this.nullTerminate = nullTerminate; |
| |
| 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 (nullTerminate ? ALGORITHM_PREFIX_0 : 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, nullTerminate), hashed); |
| } |
| } |