密码如何保存

小明是一家小公司的程序员,公司决定开发一个新系统来管理客户信息。小明负责系统的后端开发。

由于经验不足,小明最初采用的用户密码保存方式是明文保存 - 将用户注册时输入的原始密码直接保存在数据库中。

系统上线不久后,不幸发生了数据库泄露事件。黑客成功获取到数据库,从而获取到所有用户的账号和明文密码。这给用户带来很大风险。

公司高层知道此事后很生气,责骂小明没有采取密码加密的措施。小明意识到自己之前的做法确实存在很大安全隐患。

于是小明决定改进密码保存机制。他研究了一下常见的密码加密方法,选择了对密码进行Hash加密的方式。在用户注册时,小明采用Hash算法对密码进行一系列计算,得到一个固定长度的Hash值,然后将这个Hash值保存到数据库中。

此时虽然黑客无法获取明文密码去登陆其他系统,但是可以用彩虹表来倒推明文密码。

彩虹表(Rainbow table)是一种密码破解技术。

彩虹表利用密码哈希值的重复性,为已经计算过的密码和其哈希值建立索引,以加速密码破解过程。

具体工作原理:

  1. 首先生成一个可能的密码列表,如字母数字组合等。
  2. 对每个密码应用重复的哈希函數,如MD5、SHA1等,获得一系列哈希值。
  3. 将密码和其哈希值作为键值对,构建成数据库格式的大表,称为彩虹表。
  4. 当要破解一个未知哈希值时,直接在彩虹表中查找是否有匹配的哈希值键,即找到明文密码。
  5. 如果没有直接匹配,则按照哈希算法重复计算未知哈希值,一步步逼近表中哈希值,最后找到匹配密码。

安全性更高的方式

那么有没有一种更安全的方式呢?

有!Bcrypt!!!

Bcrypt是一种密码散列函数,用于存储用户密码。它工作的原理如下:

  1. 加盐:Bcrypt在密码散列前,会先随机生成一个8-24位的盐值加入计算中。不同用户密码会使用不同的随机盐值。
  2. 迭代计算:Bcrypt不像其他散列算法一次计算完成,它会重复计算密码和盐值数千次。默认迭代10次,这个数字可以调整以增加计算成本。
  3. 结果包含迭代次数和盐值:Bcrypt散列结果不仅包含密码散列值,还包括盐值和迭代次数信息。例如结果可能是:$2b$10$uF2.d0LZNZsEIBFgVFR6HOkpFFTgD3qGOqfIDSMNA0zSFjXnCXedS,这里2b表示使用Bcrypt,10代表迭代次数。
  4. 高计算成本:由于迭代计算次数多,Bcrypt对密码进行散列需要较长时间,大约每秒只能处理几百次计算。这就增加了通过暴力破解Bcrypt哈希值的难度。
  5. 盐值使彩虹表破解难度增加:与其他算法不同,Bcrypt使用的随机盐值使同一个密码产生的哈希值每次都不同,这就无法使用预计算表进行快速破解。

总之,Bcrypt通过随机盐值、高迭代次数来使密码破解成本极高,从而提供强密码散列的安全性,它是当前最常用的密码存储方法之一。

在了解了Bcrypt之后小明开始了编写demo用例,首先安装相关的依赖

$ pnpm install bcrypt
$ pnpm install @types/bcrypt -D # 类型包

新建一个index.ts文件

import bcrypt from 'bcrypt';

const args = process.argv.slice(2);
const saltRounds = 10; // 哈希杂凑次数,越多越安全

if (!args[0] || !args[1]) {
  console.log('no receive args');
}

if (args[0] === '-h') {
  const password = args[1];
  bcrypt.hash(password, saltRounds)
    .then(console.log);
}

使用ts-node执行一下,顺便验证下同样的密码hash两次结果是否一样

$ ts-node-esm ./index.ts -h 123456
# $2b$10$uF2.d0LZNZsEIBFgVFR6HOkpFFTgD3qGOqfIDSMNA0zSFjXnCXedS
$ ts-node-esm ./index.ts -h 123456
# $2b$10$h/ZUEU9V3vArMPMwyvBGkuoGYcgw.F4I9vyLuIJ5jFWF6RMwnCjsq

然后再使用生成的值验证下对比函数

if (args[0] === '-c') {
  const password = args[1];
  bcrypt.compare(password, '$2b$10$uF2.d0LZNZsEIBFgVFR6HOkpFFTgD3qGOqfIDSMNA0zSFjXnCXedS')
    .then((result) => {
      console.log(result ? 'success' : 'failed');
    });
}

再执行下验证

$ ts-node-esm ./index.ts -c 123456
# success
$ ts-node-esm ./index.ts -c 12345a
# failed

完美!

既然每次生成hash都不一样,是怎样对比的呢?

我们上面说过,生成的文本其实保留了salt和hash算法,在对比的时候会读取hash值中的salt对明文进行hash计算,对结果进行比较,如果结果一致则判定相同。


前端小白