存储用户密码应该使用什么加密算法?

概述

编程开发中,像用户登录注册这种功能很常见,那么对于用户密码处理,我们该选择什么样的加密算法呢?在这种场景下,算法需要满足下面两个条件:

  • 算法需不可逆,这样才能有效防止密码泄露。
  • 算法需相对慢,可以动态调整计算成本,缓慢是应对暴力破解有效方式。

目前来看有这么几个算法 PBKDF2BCryptSCrypt 可以满足。我们先看下旧的密码加密方式。

旧的加密

过去密码加密常用MD5或者SHA。MD5是早期设计的加密哈希,它生成哈希速度很快,随着计算机能力的增强,出现了被破解的情况,所以又有了一些长度增大的哈希函数,如:SHA-1,SHA-256等。下面是它们的一些比较:

  • MD5:速度快生成短哈希(16 字节)。意外碰撞的概率约为:1.47 \times 10^{-29}

  • SHA1:比 md5 慢 20%,生成的哈希比 MD5 长一点(20 字节)。意外碰撞的概率约为:1 \times 10^{-45}

  • SHA256:最慢,通常比 md5 慢 60%,并且生成的哈希长(32 字节)。意外碰撞的概率约为:4.3 \times 10^{-60}

为了确保安全你可能会选择目前长度最长的哈希SHA-512,但硬件能力在增强,或许有一天又会发现新的漏洞,研究人员又推出较新的版本,新版本的长度也会越来越长,而且他们也可能会发布底层算法,所以我们应该另外寻找更合适的算法。

加盐操作

密码安全,除了要选择足够可靠的加密算法外,输入数据的强度也要提升,因为密码是人设置的,其字符长度组合强度不可能一致,如果直接进行哈希存储往往会提升爆破的概率,这时我们需要加盐

加盐是密码学中经常提到的概念,其实就是随机数据。下面是一个 java 中生成盐的例子:

    public static byte[] generateSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[16];
        random.nextBytes(salt);
        return salt;
    }

SHA-512 加盐哈希密码

    public static String sha512(String rawPassword, byte[] salt) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-512");
            // 加点盐
            md.update(salt);
            return Hex.encodeHexString(md.digest(rawPassword.getBytes(StandardCharsets.UTF_8)));
        } catch (GeneralSecurityException ex) {
            throw new IllegalStateException("Could not create hash", ex);
        }
    }

PBKDF2

PBKDF1PBKDF2是一个密钥派生函数,其作用就是根据指定的密码短语生成加密密钥。之前在 常见加密算法 提到过。它虽然不是加密哈希函数,但它仍然适用密码存储场景,因为它有足够的安全性,PBKDF2 函数计算如下:
DK = PBKDF2(PRF, Password, Salt, Iterations, HashWidth)

  • PRF 是伪随机函数两个参数,输出固定的长度(例如,HMAC);
  • Password 是生成派生密钥的主密码;
  • Salt 是加密盐;
  • Iterations 是迭代次数,次数越多;
  • HashWidth 是派生密钥的长度;
  • DK 是生成的派生密钥。

PRF(HMAC)大致迭代过程,第一次时将 Password 作为密钥和Salt传入,然后再将输出结果作为输入重复完成后面迭代。

PBKDF2

HMAC:基于哈希的消息认证码,可以使用共享密钥提供身份验证。比如HMAC-SHA256,输入需要认证的消息密钥进行计算,然后输出sha256的哈希值。

PBKDF2不同于MD和SHA哈希函数,它通过增加迭代次数提升了破解难度,并且还可以根据情况进行配置,这使得它具有滑动计算成本。

对于MD5和SHA,攻击者每秒可以猜测数十亿个密码。而使用 PBKDF2,攻击者每秒只能进行几千次猜测(或更少,取决于配置),所以它适用于抗击暴力攻击。

2021 年,OWASP 建议对 PBKDF2-HMAC-SHA256 使用 310000 次迭代,对 PBKDF2-HMAC-SHA512 使用 120000 次迭代

    public static String pbkdf2Encode(String rawPassword, byte[] salt) {
        try {
            int iterations = 310000;
            int hashWidth = 256;
            PBEKeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, iterations, hashWidth);
            SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
            return Base64.getEncoder().encodeToString(skf.generateSecret(spec).getEncoded());
        } catch (GeneralSecurityException ex) {
            throw new IllegalStateException("Could not create hash", ex);
        }
    }

Bcrypt

简介

bcrypt 是基于 eksblowfish 算法设计的加密哈希函数,它最大的特点是:可以动态调整工作因子(迭代次数)来调整计算速度,因此就算以后计算机能力不断增加,它仍然可以抵抗暴力攻击。

关于eksblowfish算法,它是采用分组加密模式,并且支持动态设定密钥计算成本(迭代次数)。算法的详细介绍可查看下面文章:

https://www.usenix.org/legacy/publications/library/proceedings/usenix99/full_papers/provos/provos_html/node4.html

结构

bcrypt 函数输入的密码字符串不超过 72 个字节、包含算法标识符、一个计算成本和一个 16 字节(128 位)的盐值。通过输入计算得到 24字节(192位)哈希,最终输出格式如下:

$2a$12$DQoa2eT/aXFPgIoGwfllHuj4wEA3F71WWT7E/Trez331HGDUSRvXi
\__/\/ \____________________/\_____________________________/
Alg Cost      Salt                        Hash
  • $2a$: bcrypt 算法标识符或叫版本;
  • 12: 工作因子 (2^12 表示 4096 次迭代)
  • DQoa2eT/aXFPgIoGwfllHu: base64 的盐值;
  • j4wEA3F71WWT7E/Trez331HGDUSRvXi: 计算后的 Base64 哈希值(24 字节)。

bcrypt 版本

  • $2a$: 规定哈希字符串必须是 UTF-8 编码,必须包含空终止符。
  • $2y$: 该版本为修复 2011年6月 PHP 在 bcrypt 实现中的一个错误。
  • $2b$: 该版本为修复 2014年2月 OpenBSD 在 bcrypt 实现中的一个错误。

2014年2月 在 OpenBSD 的 bcrypt 实现中发现,它使用一个无符号的 8 位值来保存密码的长度。对于长度超过255字节的密码,密码将在72或长度模256 中的较小者处被截断,而不是被截断为72字节。例如:260 字节的密码将被截断为4个字节,而不是截断为 72 个字节。

实践

bcrypt 关键在于设定合适的工作因子,理想的工作因子没有特定的法则,主要还是取决于服务器的性能和应用程序上的用户数量,一般在安全性应用性能之间权衡设定。

假如你的因子设置比较高,虽然可以保证攻击者难以破解哈希,但是登录验证也会变慢,严重影响用户体验,而且也可能被攻击者通过大量登录尝试耗尽服务器的 CPU 来执行拒绝服务攻击。一般来说计算哈希的时间不应该超过一秒。

我们使用 spring security BCryptPasswordEncoder 看下不同因子下产生哈希的时间,我电脑配置如下:

处理器:2.2 GHz 四核Intel Core i7
内存:16 GB 1600 MHz DDR3
显卡:Intel Iris Pro 1536 MB

Map<Integer, BCryptPasswordEncoder> encoderMap = new LinkedHashMap<>();
        for (int i = 8; i <= 21; i++) {
            encoderMap.put(i, new BCryptPasswordEncoder(i));
        }
        String plainTextPassword = "huhdfJ*!4";
        for (int i : encoderMap.keySet()) {
            BCryptPasswordEncoder encoder = encoderMap.get(i);
            long start = System.currentTimeMillis();
            encoder.encode(plainTextPassword);
            long end = System.currentTimeMillis();
            System.out.println(String.format("bcrypt | cost: %d, time : %dms", i, end - start));
        }
bcrypt | cost: 8, time : 39ms
bcrypt | cost: 9, time : 45ms
bcrypt | cost: 10, time : 89ms
bcrypt | cost: 11, time : 195ms
bcrypt | cost: 12, time : 376ms
bcrypt | cost: 13, time : 720ms
bcrypt | cost: 14, time : 1430ms
bcrypt | cost: 15, time : 2809ms
bcrypt | cost: 16, time : 5351ms
bcrypt | cost: 17, time : 10737ms
bcrypt | cost: 18, time : 21417ms
bcrypt | cost: 19, time : 43789ms
bcrypt | cost: 20, time : 88723ms
bcrypt | cost: 21, time : 176704ms

拟合得到以下公式:
10.3064 \cdot e^{0.696464 x}

bcrypt

BCryptPasswordEncoder 因子范围在 4-31 ,默认是 10,我们根据公式推导一下 31时需要多长时间。

    /**
     * @param strength the log rounds to use, between 4 and 31
     */
    public BCryptPasswordEncoder(int strength) {
        this(strength, null);
    }

10.3064 \cdot e^{0.696464(31)} = 24529665567.08815

工作因子 31 时大概需要 284 天,所以我们知道使用 bcrypt 可以很容易的扩展哈希计算过程以适应更快的硬件,为我们留出很大的回旋余地,以防止攻击者从未来的技术改进中受益。

SCrypt

SCrypt 比上面提到的算法出来较晚,是Colin Percival于 2009 年 3 月创建的基于密码的密钥派生函数。关于该算法我们需要明白下面两点:

  • 该算法专门设计用于通过需要大量内存来执行大规模自定义硬件攻击,成本高昂。
  • 它属于密钥派生函数和上面提到 PBKDF2 属于同一类别。

Spring security 也实现该算法 SCryptPasswordEncoder ,输入参数如下:

  • CpuCost: 算法的 cpu 成本。 必须是大于 1 的 2 的幂。默认当前为 16,384 或 2^14)
  • MemoryCost: 算法的内存成本。默认当前为 8。
  • Parallelization: 算法的并行化当前默认为 1。请注意,该实现当前不利用并行化。
  • KeyLength: 算法的密钥长度。 当前默认值为 32。
  • SaltLength: 盐长度。 当前默认值为 64。

不过也有人提到,并不建议在生产系统中使用它来存储密码,他的结论是首先 SCrypt 设计目的是密钥派生函数而不是加密哈希,另外它实现上也并不那么完美。详细可查看下面文章。

https://blog.ircmaxell.com/2014/03/why-i-dont-recommend-scrypt.html

结论

我会推荐使用 bcrypt。为什么是 bcrypt 呢?

密码存储这种场景下,将密码哈希处理是最好的方式,第一它本身就是加密哈希函数,其次按照摩尔定律的定义,集成系统上每平方英寸的晶体管数量大约每 18 个月翻一番。在 2 年内,我们可以增加它的工作因子以适应任何变化。

当然这并不是说其它算法不够安全,你仍然可以选择其它算法。建议优先使用 bcrypt,其次是密钥派生类(PBKDF2 和 SCrypt),最后是哈希+盐(SHA256(salt))。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容