最近在寫用戶管理相關的微服務,其中比較重要的問題是如何保存用戶的密碼,加鹽哈希是一種常見的作法。知乎上有個問題你們能夠先讀一下: 加鹽密碼保存的最通用方法是?html

對於每一個用戶的密碼,都應該使用獨一無二的鹽值,每當新用戶註冊或者修改密碼時,都應該使用新的鹽值進行加密,而且這個鹽值應該足夠長,使得有足夠的鹽值以供加密。隨着彩虹表的出現及不斷增大,MD5算法不建議再使用了。java

存儲密碼的步驟算法

  1. 使用基於加密的僞隨機數生成器(Cryptographically Secure Pseudo-Random Number Generator – CSPRNG)生成一個足夠長的鹽值,如Java中的java.security.SecureRandom
  2. 將鹽值混入密碼(經常使用的作法是置於密碼以前),並使用標準的加密哈希函數進行加密,如SHA256
  3. 把哈希值和鹽值一塊兒存入數據庫中對應此用戶的那條記錄

校驗密碼的步驟spring

  1. 從數據庫取出用戶的密碼哈希值和對應鹽值
  2. 將鹽值混入用戶輸入的密碼,並使用相同的哈希函數進行加密
  3. 比較上一步結果和哈希值是否相同,若是相同那麼密碼正確,反之密碼錯誤

加鹽使攻擊者沒法採用特定的查詢表或彩虹錶快速破解大量哈希值,但不能阻止字典攻擊或暴力攻擊。這裏假設攻擊者已經獲取到用戶數據庫,意味着攻擊者知道每一個用戶的鹽值,根據Kerckhoffs’s principle,應該假設攻擊者知道用戶系統使用密碼加密算法,若是攻擊者使用高端GPU或定製的ASIC,每秒能夠進行數十億次哈希計算,針對每一個用戶進行字典查詢的效率依舊很高效。數據庫

爲了下降這類攻擊,能夠用一種叫作密鑰擴展的技術,讓哈希函數變得很慢,即便GPU或ASIC字典攻擊或暴力攻擊也會慢得讓攻擊者沒法接受。密鑰擴展的實現依賴一種CPU密集型哈希函數,如PBKDF2和本文將要介紹的Bcrypt。這類函數使用一個安全因子或迭代次數做爲參數,該值決定了哈希函數會有多慢。api

Bcrypt數組

Bcrypt是由Niels Provos和DavidMazières基於Blowfish密碼設計的一種密碼散列函數,於1999年在USENIX上發佈。安全

wikipedia上Bcrypt詞條中有該算法的僞代碼:oracle

Function bcrypt
    Input:
        cost:     Number (4..31)                // 該值決定了密鑰擴展的迭代次數 Iterations = 2^cost
        salt:     array of Bytes (16 bytes)     // 隨機數
        password: array of Bytes (1..72 bytes)  // 用戶密碼
    Output:
        hash:     array of Bytes (24 bytes)     // 返回的哈希值

    // 使用Expensive key setup算法初始化Blowfish狀態
    state <- EksBlowfishSetup(cost, salt, password)     // 這一步是整個算法中最耗時的步驟

    ctext <- "OrpheanBeholderScryDoubt"     // 24 bytes,初始向量
    repeat (64)
        ctext <- EncryptECB(state, ctext)   // 使用 blowfish 算法的ECB模式進行加密

    return Concatenate(cost, salt, ctext)

// Expensive key setup
Function EksBlowfishSetup
    Input:
        cost:     Number (4..31)
        salt:     array of Bytes (16 bytes)
        password: array of Bytes (1..72 bytes)
    Output:
        state:    opaque BlowFish state structure

    state <- InitialState()
    state <- ExpandKey(state, salt, password)
    repeat (2 ^ cost)           // 計算密集型
        state <- ExpandKey(state, 0, password)
        state <- ExpandKey(state, 0, salt)

    return state

Function ExpandKey(state, salt, password)
    Input:
        state:    Opaque BlowFish state structure  // 內部包含 P-array 和 S-box
        salt:     array of Bytes (16 bytes)
        password: array of Bytes (1..72 bytes)
    Output:
        state:    Opaque BlowFish state structure

    // ExpandKey 是對輸入參數進行固定的移位異或等運算,這裏不列出

經過僞代碼能夠看出,經過制定不一樣的cost值,可使得EksBlowfishSetup的運算次數大幅提高,從而達到慢哈希的目的。app

Spring Security 中的 Bcrypt

理解了Bcrypt算法的原理,再來看Spring Security中的實現就很簡單了。

package org.springframework.security.crypto.bcrypt;

...省略import...

public class BCryptPasswordEncoder implements PasswordEncoder {
    ...省略log...

    private final int strength;         // 至關於wiki僞代碼中的cost,默認爲10

    private final SecureRandom random;  // CSPRNG

    // 構造函數
    public BCryptPasswordEncoder() {
        this(-1);
    }

    // 至關於僞代碼中的cost, 長度 4 ~ 31
    public BCryptPasswordEncoder(int strength) {
        this(strength, null);
    }

    // 構造函數
    public BCryptPasswordEncoder(int strength, SecureRandom random) {
        if (strength != -1 && (strength < BCrypt.MIN_LOG_ROUNDS || strength > BCrypt.MAX_LOG_ROUNDS)) {
            throw new IllegalArgumentException("Bad strength");
        }
        this.strength = strength;
        this.random = random;
    }

    // 加密函數
    public String encode(CharSequence rawPassword) {
        String salt;
        if (strength > 0) {
            if (random != null) {
                salt = BCrypt.gensalt(strength, random);
            }
            else {
                salt = BCrypt.gensalt(strength);
            }
        }
        else {
            salt = BCrypt.gensalt();
        }
        return BCrypt.hashpw(rawPassword.toString(), salt);
    }

    // 密碼匹配函數
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if (encodedPassword == null || encodedPassword.length() == 0) {
            logger.warn("Empty encoded password");
            return false;
        }

        if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
            logger.warn("Encoded password does not look like BCrypt");
            return false;
        }

        return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
    }
}
package org.springframework.security.crypto.bcrypt;

public class BCrypt {

    // 生成鹽值的函數 "$2a$" + 2字節log_round + "$" + 22字節隨機數Base64編碼
    public static String gensalt(int log_rounds, SecureRandom random) {
        if (log_rounds < MIN_LOG_ROUNDS || log_rounds > MAX_LOG_ROUNDS) {
            throw new IllegalArgumentException("Bad number of rounds");
        }
        StringBuilder rs = new StringBuilder();
        byte rnd[] = new byte[BCRYPT_SALT_LEN];

        random.nextBytes(rnd);

        rs.append("$2a$");
        if (log_rounds < 10) {
            rs.append("0");
        }
        rs.append(log_rounds);
        rs.append("$");
        encode_base64(rnd, rnd.length, rs);
        return rs.toString();   // 總長度29字節
    }

    /** * Hash a password using the OpenBSD bcrypt scheme * @param password the password to hash * @param salt the salt to hash with (perhaps generated using BCrypt.gensalt) * @return the hashed password * @throws IllegalArgumentException if invalid salt is passed */
    public static String hashpw(String password, String salt) throws IllegalArgumentException {
        // 該函數在驗證階段也會用到,由於前29字節爲鹽值,因此能夠將以前計算過的密碼哈希值作爲鹽值
        BCrypt B;
        String real_salt;
        byte passwordb[], saltb[], hashed[];
        char minor = (char) 0;
        int rounds, off = 0;
        StringBuilder rs = new StringBuilder();

        if (salt == null) {
            throw new IllegalArgumentException("salt cannot be null");
        }

        int saltLength = salt.length();

        if (saltLength < 28) {
            throw new IllegalArgumentException("Invalid salt");
        }

        if (salt.charAt(0) != '$' || salt.charAt(1) != '2') {
            throw new IllegalArgumentException("Invalid salt version");
        }
        if (salt.charAt(2) == '$') {
            off = 3;
        }
        else {
            minor = salt.charAt(2);
            if (minor != 'a' || salt.charAt(3) != '$') {
                throw new IllegalArgumentException("Invalid salt revision");
            }
            off = 4;
        }

        if (saltLength - off < 25) {
            throw new IllegalArgumentException("Invalid salt");
        }

        // Extract number of rounds
        if (salt.charAt(off + 2) > '$') {
            throw new IllegalArgumentException("Missing salt rounds");
        }
        rounds = Integer.parseInt(salt.substring(off, off + 2));

        real_salt = salt.substring(off + 3, off + 25);
        try {
            passwordb = (password + (minor >= 'a' ? "\000" : "")).getBytes("UTF-8");
        }
        catch (UnsupportedEncodingException uee) {
            throw new AssertionError("UTF-8 is not supported");
        }

        // 解析成16字節的字節數組
        saltb = decode_base64(real_salt, BCRYPT_SALT_LEN);

        // 這裏new了一個新的對象是由於會用到BCrypt中int P[]和 int S[],擴展的密鑰存放在這兩個結構體
        B = new BCrypt();
        hashed = B.crypt_raw(passwordb, saltb, rounds);

        rs.append("$2");
        if (minor >= 'a') {
            rs.append(minor);
        }
        rs.append("$");
        if (rounds < 10) {
            rs.append("0");
        }
        rs.append(rounds);
        rs.append("$");
        encode_base64(saltb, saltb.length, rs);
        encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
        return rs.toString();
    }

    /** * Perform the central password hashing step in the bcrypt scheme * @param password the password to hash * @param salt the binary salt to hash with the password * @param log_rounds the binary logarithm of the number of rounds of hashing to apply * @return an array containing the binary hashed password */
    private byte[] crypt_raw(byte password[], byte salt[], int log_rounds) {
        int cdata[] = (int[]) bf_crypt_ciphertext.clone();
        int clen = cdata.length;
        byte ret[];

        // rounds = 1 << log_round
        long rounds = roundsForLogRounds(log_rounds);

        init_key();
        ekskey(salt, password);
        for (long i = 0; i < rounds; i++) { // 最耗時的密鑰擴展
            key(password);
            key(salt);
        }

        for (int i = 0; i < 64; i++) {
            for (int j = 0; j < (clen >> 1); j++) {
                encipher(cdata, j << 1);
            }
        }

        ret = new byte[clen * 4];
        for (int i = 0, j = 0; i < clen; i++) {
            ret[j++] = (byte) ((cdata[i] >> 24) & 0xff);
            ret[j++] = (byte) ((cdata[i] >> 16) & 0xff);
            ret[j++] = (byte) ((cdata[i] >> 8) & 0xff);
            ret[j++] = (byte) (cdata[i] & 0xff);
        }
        return ret;
    }

    /** * Check that a plaintext password matches a previously hashed one * @param plaintext the plaintext password to verify * @param hashed the previously-hashed password * @return true if the passwords match, false otherwise */
    public static boolean checkpw(String plaintext, String hashed) {
        return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed));
    }

    static boolean equalsNoEarlyReturn(String a, String b) {
        char[] caa = a.toCharArray();
        char[] cab = b.toCharArray();

        if (caa.length != cab.length) {
            return false;
        }

        byte ret = 0;
        for (int i = 0; i < caa.length; i++) {
            ret |= caa[i] ^ cab[i];
        }
        return ret == 0;
    }
}

性能

慢哈希既要防止攻擊者沒法使用暴力破擊,又不能影響用戶體驗,因爲機器性能的差別,獲取強度參數最好的辦法就是執行一個簡短的性能基準測試,找到使哈希函數大約耗費0.5秒的值。

public class BCryptBench {
    public static void main(String[] args) {
        long startTime, endTime, duration;

        // the default strength 10
        BCryptPasswordEncoder bCryptPasswordEncoder10 = new BCryptPasswordEncoder();
        duration = 0;

        for (int i = 0; i < 10; i++) {
            startTime = System.currentTimeMillis();
            System.out.println(bCryptPasswordEncoder10.encode("admin"));
            endTime = System.currentTimeMillis();
            duration += (endTime - startTime);
        }

        System.out.println(duration / 10.0);    // 88.4ms

        // strength 11
        // the default strength 10
        BCryptPasswordEncoder bCryptPasswordEncoder11 = new BCryptPasswordEncoder(11);
        duration = 0;

        for (int i = 0; i < 10; i++) {
            startTime = System.currentTimeMillis();
            System.out.println(bCryptPasswordEncoder11.encode("admin"));
            endTime = System.currentTimeMillis();
            duration += (endTime - startTime);
        }

        System.out.println(duration / 10.0);    // 175.3ms

        // strength 12
        BCryptPasswordEncoder bCryptPasswordEncoder12 = new BCryptPasswordEncoder(12);
        duration = 0;

        for (int i = 0; i < 10; i++) {
            startTime = System.currentTimeMillis();
            System.out.println(bCryptPasswordEncoder12.encode("admin"));
            endTime = System.currentTimeMillis();
            duration += (endTime - startTime);
        }

        System.out.println( duration / 10.0);   // 344.3ms

        // strength 13
        BCryptPasswordEncoder bCryptPasswordEncoder13 = new BCryptPasswordEncoder(13);
        duration = 0;

        for (int i = 0; i < 10; i++) {
            startTime = System.currentTimeMillis();
            System.out.println(bCryptPasswordEncoder13.encode("admin"));
            endTime = System.currentTimeMillis();
            duration += (endTime - startTime);
        }

        System.out.println(duration / 10.0);    // 703.8ms

        // strength 14
        BCryptPasswordEncoder bCryptPasswordEncoder14 = new BCryptPasswordEncoder(14);
        duration = 0;

        for (int i = 0; i < 10; i++) {
            startTime = System.currentTimeMillis();
            System.out.println(bCryptPasswordEncoder14.encode("admin"));
            endTime = System.currentTimeMillis();
            duration += (endTime - startTime);
        }

        System.out.println(duration / 10.0);    // 1525.0ms

        // strength 15
        BCryptPasswordEncoder bCryptPasswordEncoder15 = new BCryptPasswordEncoder(15);
        duration = 0;

        for (int i = 0; i < 10; i++) {
            startTime = System.currentTimeMillis();
            System.out.println(bCryptPasswordEncoder15.encode("admin"));
            endTime = System.currentTimeMillis();
            duration += (endTime - startTime);
        }

        System.out.println(duration / 10.0);    // 2921.9ms
    }
}

從測試的結果能夠看出,若是想選定一個執行時間爲0.5秒的慢哈希,須要將Bcrypt函數的強度設置爲12或13。而在咱們本身的微服務中,使用了默認的強度10。



原文地址:https://zhjwpku.com/2017/11/30/bcrypt-in-spring-security.html