上回讲解《使用浏览器的计算力,对抗密码破解》时,曾提到「零知识证明」的概念。
现在,我们讲解零知识证明的另一个案例。不过和上回不同,这次探讨的则是后端,完全不涉及前端。
所谓零知识证明,就是「证明过程中不透露有意义的信息」。按照这个理念,我们来思考下,平时在数据库中存储用户名和密码时,是否还可以有进一步的改进?
大多时候,我们都是将密码和用户名一一对应存储的。例如:
table_userinfo
username | hashed password | other info |
---|---|---|
alice | e37a 781a 0d06 47eb | ... |
bob | 8fe4 516a de2f b73c | ... |
... | ... | ... |
一个账号对应一个密码,这是很自然的想法,实现起来也很方便。而且密码是经过 hash 的,也算是比较安全的。
不过,尽管密码没有透露,但它和账号的对应关系却是明确的。并且获得这个表,就能获得所有用户名。攻击者可针对某个有价值的用户,进行暴力破解。
我们能否对数据库进行调整,使认证所需的数据,既不透露用户名、也不透露密码?
既然不能透露用户名和密码的对应关系,那显然需要一个单独的表来存放它们。
事实上,这个表只需一个字段就可以了,只储存 <用户名,密码> 二元组的 hash 值:
key = hash(username, password)
这样,唯一的 <用户名, 密码> 对应唯一的 key;而通过 key 既看不出用户名,也看不出密码!
table_key_set
key |
---|
20d2 7523 3cb5 7dbd |
56ef ae28 9f39 c83f |
... |
其他和密码无关的资料,仍储存在「资料表」里:
table_userinfo
username | other info |
---|---|
alice | ... |
bob | ... |
... | ... |
(注意:资料表里的「用户名」仍是明文的,但它不属于认证需要的数据)
注册时,先通过「资料表」查询用户名是否已注册,保证用户名是唯一的。然后将 key 添加到 key_set 表里,其他信息则写入资料表。
登录时,服务器根据提交上来的 <用户名,密码>,使用同样的算法算出 key,并检索是否存在于 key_set 表中:若存在,则认证成功;反之,则认证失败。
认证成功后,即可根据「用户名」访问「资料表」中相应的记录,进行具体的业务操作。
修改密码,同样很简单,只需删除旧的 key,添加新的 key 就可以。注销用户也同理,直接删除 key 即可。
这样,认证相关的数据中,就不会出现任何有意义的信息了,甚至连用户名都没有。同时并不影响正常使用,因此符合零知识证明的理念。
A:将所有账号的认证信息混在一起,会相互干扰吗?比如用户 A 和用户 C 使用相同的密码,会不会有影响?
Q:显然不会。因为 key 并不仅仅代表密码,同时还蕴含了用户名。只有当 用户名、密码同时符合时,才能匹配到数据库中的 key。只要有一项不符合,仍然查无此 key。
A:key 之间会存在冲突吗?
Q:尽管用户名是唯一的,但 key 还结合了密码因素,因此不能保证所有 key 绝对唯一,理论上仍有冲突的可能。
不过无需担心,只要 key 足够长就能有效避免。例如选择 32 字节,那么空间就有 256^32 ≈ 10^77。即使网站有 10 亿用户,冲突的几率仍小得忽略不计。
A:账号 <"jack", "123456"> 和账号 <"jack1", "23456"> 的 key 会不会一样?
Q:当然不会。二元组的 hash 值,显然不能先合并,再计算。而必须先单独计算,合并后再计算。现实中我们可以用 HMAC 函数,例如:
key = hmac_sha256(username, password)
一般需要同时 hash 两个参数的场合,用 HMAC 是最方便的,并且更权威。(千万不要想当然的将两个参数拼接后 hash,问题非常多)
A:虽然 key_set 表的数据是无意义的,但其他表仍会泄露用户名等信息,这样还有意义吗?
Q:我们的目的并不是防止「用户名」等信息的泄露,而是抹掉「用户名」与「密码」之间的关联,让破解更麻烦,下面会讲解。
另外,还可针对 key_set 表特殊保护,例如使用独立的数据库、甚至单独的服务器上,降低被牵连的风险。
A:如果有人忘记了密码,那么这个用户的 key 是不是永远不知道了?
Q:是的,不知道密码就无法算出 key。不过重置某个用户的密码还是可以实现的,后面我们会讨论。
用了该方案之后,即使 key_set 表泄露,用户名及密码都不会暴露。攻击者还得设法获得其他表,或从网站上爬取数据,才能获得实际的用户名。
事实上,即使知道某个用户名,也无法找到对应的 key —— 因为计算 key 不仅需要用户名,还要密码。光知道用户名是不够的。
不过确定了用户名之后,是否可以暴力破解其密码?显然是可以的。
假设攻击者确定有个叫 alice 的用户名,密码是个数字,那么可通过类似如下的逻辑进行猜测:
for i = 0 to 10000000
key = hash('alice', i)
# 查询该 key 是否存在
if table_key_set.exist(key) then
print '破解成功,密码是', i
break
end
end
和传统破解不同的是,现在每判断一次,就要查表一次。这样成本就大幅增加了!
传统破解,每次判断仅仅是简单的比对而已:
for i = 0 to 10000000
if hash(i) == "e37a781a0d0647eb" then
# 成功
end
end
而查表再优化,显然要比这种「简单比对」慢不少。并且很多破解工具也没有提供类似的解决方案,因此又增加了实现复杂度。
作为防守方,也可以人为提高查表门槛 —— 我们往 key_set 表里填充大量无用数据,故意将表撑大。而攻击者并不知道哪些是真实的,哪些是无用的,只能都将它们进行处理,这样就增加了破解所需的资料。
如果攻击者直接用现成数据库查表的话,那效率显然会非常低,破解时「跑字典」的速度 hash/s 将被限制在数据库查询速度 query/s 上。除非使用精心优化的查表算法,才能改进效率。
另外,表的体积撑大后,也极大增加了拖库时的下载成本。
前面为了简单描述,我们省略了和「盐」相关的东西,现在将其补回来。
由于盐需要和用户名关联,因此无法存储在 key_set 表中,只能存放其他表中,例如存在资料表里。
用户注册时,生成一个「随机长串」作为盐,并保存。接着用 <用户名,密码,盐> 三元组的 hash 值作为 key:
key = hash(username, password, salt)
其他的步骤则保持不变。
登录时,先根据用户名查询出相应的盐,然后使用同样的方式计算 key。这样,就把盐融入到 key 里面了。
加盐还是很有必要的。毕竟用户名和密码大多是有规律的,如果不加盐,那么 key 也是防不住彩虹表的。
由于用户名和密码不再有关联,因此一旦有用户忘了密码,想对其进行重置,这就非常棘手了。因为不知道密码,就无法知道对应的 key。
然而不用担心,解决方案还是有的。首先,可通过其他手段,证明这个帐号的拥有权,比如短信、邮箱等。
通过认证后,用户就可以设置新密码了。然后系统计算出新的 key,添加到 key_set 表里,旧的 key 则保持残留(也没法知道是哪个了)。
但这样有问题吗?显然有!如果旧密码以后想起来了,那还是可以登录的。这样一个帐号就可以用多个密码登录,显然不安全。因此还得改进。
事实上,只要在重置密码时,将用户的「盐」也重新生成,就可以避免这个问题了。因为用新盐算出的新 key,和旧 key 完全沾不上边了。
new_key = hash(username, password, new_salt)
同时,旧盐一旦被覆盖,就永远消失了,没有任何人知道。无法知道盐,就无法破解 key。所以那些残留的 key,是不会出卖曾经用过的密码的!
唯一的缺陷,就是残留的 key 会白白占用一条记录。不过我们本来就有意将 key_set 表撑大,因此也就不在乎这些残留数据了。
这里的巧妙之处,在于「一盐两用」。最新的盐,则是防止拖库后的彩虹表攻击;曾经用过的盐,则起到「密钥」的作用,并且这个密钥已从世上消失了,因此残留的记录是非常可靠的。
如果真担心用户不断重置密码,将数据库撑爆的话,倒是可以限制重置密码的频率。
另外,人为填充的「无用记录」,也可备份到安全的地方。将来数据库过大时,则可删除一些无用记录。
和传统存储方案相比,该方案将敏感信息独立存储,并且不再显式透露「用户名」和「密码」的关系。
虽然从算法上看,该方案并没有提升暴力破解的难度,但从工程化角度来看,倒是增加了暴力破解的复杂度。
只要查表过程优化的不够好,那么「跑字典」的速度就难以提升,成为密码破解的瓶颈。