由于大多数用户将在不同的应用程序之间重复使用密码,因此即使应用程序或数据库遭到破坏,存储密码的方式也要防止攻击者获取密码,这一点很重要。与密码学的大多数领域一样,需要考虑许多不同的因素,但幸运的是,大多数现代语言和框架都提供了内置功能来帮助存储密码,这处理了大部分复杂性。
本小抄就需要考虑的与存储密码相关的各个方面提供了指导。简而言之:
散列和加密是经常混淆或使用不当的两个术语。它们之间的关键区别在于散列是单向函数(即,不可能解密散列并获得原始值),而加密是双向函数。
在几乎所有情况下,密码都应该是散列的,而不是加密的,因为这会使攻击者很难或不可能从散列中获取原始密码。
只有在需要能够获取原始密码的边缘情况下才应使用加密。以下是可能需要这样做的一些示例:
如果应用程序需要使用密码针对不支持SSO的外部旧系统进行身份验证。
解密密码的能力存在严重的安全风险,因此应该对其进行全面的风险评估。在可能的情况下,应使用替代体系结构,以避免需要以加密形式存储密码。
此小抄重点介绍密码散列-有关加密密码的进一步指导,请参阅加密存储小抄。
虽然无法解密密码散列以获得原始密码,但在某些情况下可以破解散列。基本步骤包括:
然后对大量潜在候选密码重复此过程,直到找到匹配项。有大量不同的方法可用于选择候选密码,包括:
不能保证破解过程一定会成功,成功率将取决于多个因素:
用现代散列算法存储的强密码应该是攻击者实际上不可能破解的。
盐是作为散列过程的一部分添加到每个密码的唯一的、随机生成的字符串。由于盐对于每个用户都是唯一的,所以攻击者必须使用各自的盐一次破解一个散列,而不是一次计算一次散列并将其与每个存储的散列进行比较。这使得破解大量散列变得非常困难,因为所需的时间与散列的数量成正比增长。
Salting还提供保护,防止攻击者使用彩虹表或基于数据库的查找预先计算散列。最后,加盐意味着在不破解散列的情况下无法确定两个用户是否具有相同的密码,因为即使密码相同,不同的加盐也会导致不同的散列。
现代散列算法(如Argon2或bcrypt)会自动对密码进行加密,因此在使用它们时不需要额外的步骤。但是,如果您使用的是传统密码散列算法,则需要手动实施加盐。执行此操作的基本步骤包括:
除了腌制之外,还可以使用胡椒来提供额外的保护层。它类似于盐,但有两个主要区别:
胡椒在所有存储的密码之间共享,而不是像盐一样是唯一的。
如果攻击者只有访问数据库的权限(例如,如果他们利用了SQL注入漏洞或获得了数据库的备份),则Pepper的用途是防止攻击者能够破解任何散列。
胡椒的长度应至少为32个字符,并且应随机生成。应使用操作系统提供的安全存储API将其存储在应用程序配置文件中(使用适当的权限进行保护),或存储在硬件安全模块(HSM)中。
传统上,胡椒的使用方式类似于盐,在散列之前将其与密码连接在一起,使用诸如hash($Pepper)这样的结构。$PASSWORD)。
另一种方法是像往常一样对密码进行散列,然后使用对称加密密钥对散列进行加密,然后将其存储在数据库中,密钥充当胡椒。这避免了传统胡椒方法的一些问题,而且如果胡椒被认为是受损的,它可以更容易地旋转胡椒。
辣椒的主要问题是它们的长期维护。更改使用中的胡椒将使存储在数据库中的所有现有密码无效,这意味着在胡椒被泄露的情况下不能轻易更改密码。
一种解决方案是将胡椒的ID与相关的密码散列一起存储在数据库中。当需要更新胡椒时,可以使用新胡椒更新此ID以获取散列。尽管应用程序需要存储当前正在使用的所有辣椒,但这确实提供了一种替换受损辣椒的方法。
工作因子实质上是为每个密码执行的散列算法的迭代次数(通常实际上是2^工作迭代)。工作因数的目的是使计算哈希的计算成本更高,从而降低攻击者尝试破解密码哈希的速度。功因数通常存储在散列输出中。
在选择工作因素时,需要在安全性和性能之间取得平衡。较高的工作系数将使攻击者更难破解散列,但也会使验证登录尝试的过程变慢。如果工作因数过高,这可能会降低应用程序的性能,还可能被攻击者用来通过大量登录尝试耗尽服务器的CPU来执行拒绝服务攻击。
理想的工作因子没有金科玉律-它将取决于服务器的性能和应用程序上的用户数。确定最佳工作因数需要在应用程序使用的特定服务器上进行实验。一般来说,计算一个散列应该不到一秒,尽管在流量较高的站点上,计算散列的时间应该比这少得多。
具有功因数的一个关键优点是,随着硬件变得更强大和更便宜,它可以随着时间的推移而增加。以摩尔定律(即,给定价格点的计算能力每18个月翻一番)为粗略近似值,这意味着功因数应该每18个月增加1。
升级工作因数最常见的方法是等待用户下次进行身份验证,然后使用新的工作因数重新散列其密码。这意味着不同的哈希将具有不同的工作因子,如果用户不重新登录到应用程序,可能会导致哈希永远不会升级。根据应用程序的不同,可能适合删除较旧的密码散列,并要求用户下次需要登录时重置其密码,以避免存储较旧且安全性较低的散列。
在某些情况下,可以在没有原始密码的情况下增加散列的工作因子,尽管bcrypt和PBKDF2等常见散列算法不支持这一点。
某些散列算法(如bcrypt)具有输入的最大长度,对于大多数实现来说是72个字符(有一些报告称其他实现具有较低的最大长度,但在撰写本文时还没有确定)。在使用bcrypt的情况下,应该强制输入最大长度为64个字符,因为这提供了足够高的限制,同时仍然允许字符串终止问题,并且不会显示应用程序使用bcrypt。
此外,由于现代散列函数在计算上非常昂贵,如果用户可以提供非常长的密码,则存在潜在的拒绝服务漏洞,例如2013年发布在Django上的漏洞。
为了防止这两个问题,应该强制使用最大密码长度。对于bcrypt,此字符数应为64个字符(由于算法和实现中的限制),对于其他算法,此字符数应介于64到128个字符之间。
另一种方法是使用诸如SHA-256之类的快速算法来预散列用户提供的密码,然后使用诸如bcrypt(即,bcrypt(sha256($password)之类的更安全的算法来散列所得到的散列。虽然此方法解决了用户对速度较慢的散列算法的任意长度输入的问题,但它也引入了一些漏洞,使攻击者能够更容易地破解散列。
如果攻击者能够从两个不同的来源获取密码散列,其中一个来源使用bcrypt(sha256($password))存储密码,另一个来源将密码存储为纯sha256($password),并且攻击者可以使用来自第二个站点的未破解的SHA-256散列作为候选密码,尝试破解来自第一个(更安全的)站点的散列。如果在两个站点之间重复使用密码,这可以有效地允许攻击者剥离bcrypt层,并破解容易得多的SHA-256密码。
使用SHA-256进行预散列还意味着攻击者暴力强制散列的密钥空间是2^256,而不是上限为64个字符的密码的2420(尽管这两个密钥都足够大,不会产生任何实际影响)。
最后,在使用预散列时,请确保第一个散列算法的输出安全地编码为十六进制或base64,因为如果输入包含空字节,某些散列算法(如bcrypt)可能会以不受欢迎的方式运行。
因此,首选选项通常应该是限制最大密码长度。密码的预散列应仅在有特定要求且已采取适当步骤缓解上述问题的情况下执行。
有许多专门为安全存储密码而设计的现代散列算法。这意味着它们应该很慢(不同于MD5和SHA-1等被设计为快速的算法),它们的速度可以通过更改工作因子来配置。
Argon2是2015年密码散列大赛的获胜者。该算法有三个不同的版本,应该在可用的情况下使用Argon2id变体,因为它提供了一种平衡的方法来抵抗旁路攻击和基于GPU的攻击。
与其他算法不同的是,Argon2有三个可以配置的不同参数,这意味着要针对环境进行正确的调优要复杂得多。该规范包含选择适当参数的指导,但是,如果您不能适当地调优它,那么更简单的算法(如bcrypt)可能是更好的选择。
PBKDF2是NIST推荐的,具有经过FIPS-140验证的实施。因此,当需要这些时,它应该是首选算法。此外,它在.NET框架中受到开箱即用的支持,因此通常在ASP.NET应用程序中使用。
PBKDF2可以与基于多种不同散列算法的HMAC一起使用。HMAC-SHA-256受到广泛支持,并由NIST推荐。
PBKDF2的工作因子是通过迭代计数实现的,迭代计数应至少为10,000(尽管在安全性较高的环境中,最大值可能为100,000)。
bcrypt是最受支持的算法,除非对PBKDF2有特定要求,或者有适当的知识来调优Argon2,否则应该将其作为默认选择。
bcrypt的默认工作因子是10,除非在较旧或功率较低的系统上运行,否则通常应该将其提高到12。
在某些情况下,通常由于使用遗留语言或环境,不可能使用现代散列算法。在可能的情况下,应该使用第三方库来提供这些算法。但是,如果唯一可用的算法是MD5和SHA-1等传统算法,则可以采取许多步骤来提高存储密码的安全性。
使用加密安全的随机数生成器生成的每个密码使用唯一的SALT。
使用非常多的算法迭代次数(至少10,000次,根据硬件的速度可能更多)。
应该强调的是,这些步骤不如使用现代散列算法,并且只有在没有其他选择的情况下才应该采取这种方法。
对于使用不太安全的散列算法(如MD5或SHA-1)构建的较旧的应用程序,这些散列应该升级到更现代、更安全的散列。当用户下一次输入他们的密码时(通常通过在应用程序上进行身份验证),应该使用新算法对其进行重新散列。让用户当前密码过期并要求他们输入新密码也是一种好做法,这样他们密码的任何较旧(安全性较低)的散列对攻击者都不再有用。
但是,这种方法意味着旧的(不太安全的)密码散列将存储在数据库中,直到用户下次登录,并且可能会无限期存储。可以采取两种主要方法来解决此问题。
一种方法是过期并删除长期不活动的用户的密码散列,并要求他们重新设置密码才能再次登录。虽然安全,但这种方法对用户不是特别友好,并且大量用户的密码过期可能会给支持人员带来问题,或者可能被用户解释为入侵的指示。但是,如果在登录时实现密码散列升级代码与删除旧密码散列之间存在合理的延迟,则大多数活动用户应该已经更改了他们的密码。
另一种方法是使用现有密码散列作为更安全算法的输入。例如,如果应用程序最初将密码存储为MD5($PASSWORD),则可以轻松将其升级为bcrypt(MD5($PASSWORD))。以这种方式分层散列避免了知道原始密码的需要,但是它可以使散列更容易被破解,如预散列一节中所讨论的那样。因此,这些散列应该在用户下次登录时替换为用户密码的直接散列。
编写自定义加密代码(如散列算法)真的很难,永远不应该在学术练习之外完成。使用未知或定制的算法可能带来的任何潜在好处都会被其中存在的弱点所掩盖。