⤴Top⤴

bcrypt 与密码安全

博客分类: 后端

bcrypt 与密码安全

bcrypt 与密码安全

存储哈希值

哈希算法是一种单向函数。它把任意数量的数据转换为固定长度的“指纹”,而且这个过程无法逆转。它们有这样的特性:如果输入发生了一点改变,由此产生的哈希值会完全不同。像 SHA256、SHA512、RIPEMD 和 WHIRLPOOL 都是加密哈希函数:

sha_256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
sha_256("hallo") = d3751d33f9cd5049c4af2b462735457e4d3baf130bcbb87f389e349fbaeb20b9

可以在线测试下各种不同的加密哈希函数,点击这里 👈

而破解哈希有以下两种方法:

查表法

Searching: 5f4dcc3b5aa765d61d8327deb882cf99: FOUND: password5
Searching: 6cbe615c106f422d23669b610b564800:  not in database
Searching: 630bf032efe4507f2c57b280995925a9: FOUND: letMEin12

对于破解相同类型的哈希值,查表法(Lookup Tables)是一种非常高效的方式。主要理念是预先计算出密码字典中的每个密码的哈希值,然后把他们相应的密码存储到一个表里。一个设计良好的查询表结构,即使包含了数十亿个哈希值,仍然可以实现每秒钟查询数百次哈希。

反向查表法

Searching for hash(apple) in users' hash list...     : Matches [alice3, 0bob0, charles8]
Searching for hash(blueberry) in users' hash list... : Matches [usr10101, timmy, john91]

反向查表法(Reverse Lookup Tables)是攻击者从被黑的用户帐号数据库创建一个用户名和对应的密码哈希表,然后,攻击者猜测一系列哈希值并使用该查询表来查找使用此密码的用户。通常许多用户都会使用相同的密码,因此这种攻击方式特别有效。

彩虹表

彩虹表(Rainbow Tables)是一种以空间换时间的技术。与查表法相似,只是它为了使查询表更小,牺牲了破解速度。因为彩虹表更小,所以在单位空间可以存储更多的哈希值,从而使攻击更有效,可以查看这里 👈

哈希碰撞

由于哈希函数将任意大小的数据转化为定长的字符串,因此,必定有一些不同的输入经过哈希计算后得到了相同的字符串的情况。碰撞攻击(Hash Collisions)即是如此,指存在一个和用户密码不同的字符串,却有相同的哈希值。然而,即使是像 MD5 这样的脆弱的哈希函数找到碰撞也需要大量的专门算力(dedicated computing power),所以在实际中“意外地”出现哈希碰撞的情况不太可能。对于实用性而言,加盐 MD5 和加盐 SHA256 的安全性一样。尽管如此,可能的话,要使用更安全的哈希函数,比如 SHA256、SHA512、RipeMD 或 WHIRLPOOL 。

加盐

sha_256("hello")                    = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
sha_256("hello" + "QxLUF1bgIAdeQX") = 9e209040c863f84a31e719795b2577523954739fe5ed3b58a75cff2127075ed1
sha_256("hello" + "bv5PehSMfV11Cd") = d1d3ec2e6f20fd420d50e2642992841d8338a314b8ea157c9e18477aaef226ab

如上例所示,这使得相同的密码每次都被加密为完全不同的字符串。我们需要盐值来校验密码是否正确。通常和密码哈希值一同存储在帐号数据库中,或者作为哈希字符串的一部分。使用盐值时我们应当避免盐值重复(Salt Reuse)短盐值(Short Salt),这样仍然能够通过上述的查询表和彩虹表来破解。

盐值应该使用加密的安全伪随机数生成器(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG)产生。CSPRNG 被设计成用于加密安全,这意味着它能提供高度随机、完全不可预测的随机数。最后该盐应和密码哈希一起存储在用户帐号表中。

JS 可以使用 Crypto​.get​Random​Values() 获取符合密码学要求的安全的随机值:

// 语法
// typedArray 是一个基于整数的 TypedArray,它可以是 Int8Array、Uint8Array、Int16Array、 Uint16Array、 Int32Array 或者 Uint32Array。在数组中的所有的元素会被随机数重写
cryptoObj.getRandomValues(typedArray);
/* 假设 window.crypto.getRandomValues 可用 */

var array = new Uint32Array(10);
window.crypto.getRandomValues(array);

console.log("Your lucky numbers:");
for (var i = 0; i < array.length; i++) {
  console.log(array[i]);
}

HMAC

HMAC(Hash-based message authentication code)散列消息认证码,它可以用来保证数据的完整性,同时可以用来作某个消息的身份验证。它本质上是基于 key 的普通哈希算法,以一个密钥和一个消息为输入,生成一个消息摘要作为输出。通常的表示法是 H(m,k)= h ,其中 H 是 HMAC 哈希算法, m 是消息,k 是秘钥,h 是生成的哈希值。让我们看看在 node crypto 模块是怎么实践的:

// hmac.js
const filename = process.argv[2];
const crypto = require('crypto');
const fs = require('fs');

// a secret 即为设定的 key,存于服务器端
const hmac = crypto.createHmac('sha256', 'a secret');

// hmac.update('some data to hash')
// console.log(hmac.digest('hex'))

const input = fs.createReadStream(filename);
input.on('readable', () => {
  const data = input.read();
  if (data)
    hmac.update(data);
  else {
    console.log(`${hmac.digest('hex')} ${filename}`);
  }
});

之后运行命令 node hmac.js hmac.js 即可计算这个文件的 hmac 值:

801da0ee24198b3293ee128f521b41f65303c4f6f0f919ab594afd7169198188 hmac.js

然而不管是普通哈希算法还是 HMAC,其设计初衷就是为了加快计算(computationally fast),这是个双刃剑,同时也会导致密码变得不安全,容易被破解。因此关键在于“慢”。

慢哈希函数

加盐可以确保攻击者无法使用像查询表和彩虹表攻击那样对大量哈希值进行破解,但依然不能阻止他们使用字典攻击或暴力攻击。高端显卡( GPU )和定制的硬件每秒可以进行十亿次哈希计算,所以这些攻击还是很有效的。为了降低使这些攻击的效率,我们可以使用一个叫做密钥扩展(key stretching)的技术。终极目标是使哈希函数的速度慢到足以令攻击者放弃,但由此造成的延迟又不至于引起用户的注意。

bcrypt

bcrypt 中的 b 代表了 Blowfish(一个对称密钥加密算法)crypt 则代表了 UNIX password system 所使用的哈希函数的名称,其伪代码如下,类似的算法还有 PBKDF2scrypt 等:

hash = bcrypt(plain_password, gensalt(log_rounds))

bcrypt was designed for password hashing hence it is a slow algorithm. This is good for password hashing as it reduces the number of passwords by second an attacker could hash when crafting a dictionary attack. “ via @auth0

让我们来看看在 node 中的表现,可以在这里找到 bcrypt 库,先看看 log_rounds 对于哈希算法速度的影响:

const bcrypt = require("bcrypt");
const plainTextPassword1 = "DFGh5546*%^__90";

for (let saltRounds = 10; saltRounds < 21; saltRounds++) {
  console.time(`bcrypt | cost: ${saltRounds}, time to hash`);
  bcrypt.hashSync(plainTextPassword1, saltRounds);
  console.timeEnd(`bcrypt | cost: ${saltRounds}, time to hash`);
}

我们可以看到打印的速度,当 cost 越大时,耗时越长,而且是呈几何级数的增长 😱:

bcrypt | cost: 10, time to hash: 57.429ms
bcrypt | cost: 11, time to hash: 113.424ms
bcrypt | cost: 12, time to hash: 226.857ms
bcrypt | cost: 13, time to hash: 451.617ms
bcrypt | cost: 14, time to hash: 902.370ms
bcrypt | cost: 15, time to hash: 1800.152ms
bcrypt | cost: 16, time to hash: 3637.410ms
bcrypt | cost: 17, time to hash: 7505.599ms

简单来说 bcrypt 算法就是重复计算内部的加密(散列)函数很多次,所以减慢了整体运算速度,而且算法内置给每个不同的 hash 一个不同的盐。

让我们看看实际中的应用,为了方便看到创建盐的过程,我们把 hash 方法做个简单拆分:

const bcrypt = require("bcrypt");
const saltRounds = 10;
const plainTextPassword1 = "DFGh5546*%^__90";

bcrypt
  // .hash(plainTextPassword1, saltRounds)
  .genSalt(saltRounds)
  .then(salt => {
    console.log(`Salt: ${salt}`);

    return bcrypt.hash(plainTextPassword1, salt);
  })
  .then(hash => {
    console.log(`Hash: ${hash}`);

    // Store hash in your password DB.
  })
  .catch(err => console.error(err.message));

打印结果如下,并且根据打印结果的关系,我们可以总结出下面这张图(图来自这里):

<!-- 每次运行打印结果都不一致 -->
Salt: $2b$10$jyCfulMroBf7R3f/SJsOAu
Hash: $2b$10$jyCfulMroBf7R3f/SJsOAu9hLB9io.RpwpJAY/LIeXNX8Sf6QKCqK

bcrypt

那我们如何跟用户输入的密码进行校验呢,下面用到 compare 这个方法:

// 假设我们存储的 hash 如下
const hash = "$2b$10$69SrwAoAUNC5F.gtLEvrNON6VQ5EX89vNqLEqU655Oy9PeT/HRM/a";

bcrypt
  .compare(plainTextPassword1, hash)
  .then(res => {
    console.log(res);
  })
  .catch(err => console.error(err.message));

那么 compare 方法又是怎么知道密码是否匹配呢?我们可以再看看之前打印的 salt 和 hash,bcrypt.compare 会根据存储的 hash 来推断盐值,然后根据该盐值算出该密码的哈希值,从而进行比较。弯得否 👍

参考链接

  1. 如何正确对用户密码进行加密? By Defuse Security
  2. Hashing in Action: Understanding bcrypt By Dan Arias
  3. 如何安全的存储用户密码 By luokuning
  4. Is a HMAC-ed password is more secure than a bcrypt-ed or scrypt-ed password? - stackoverflow