密码加密存储技术详解(Password Storage Cheat Sheet)

2021-01-22 17:43 阅读

从最早的明文保存密码,到md5 sha1 sha256 sha512加密,到加salt、加pepper、多次hash计算,再到现代的密码加密算法Bcrypt PBKDF2 Argon2id。在保护用户密码的过程中,软件工程师作出了巨大的努力,为网络安全的建设添砖加瓦。

本文详细的描述了密码加密存储技术涉及到的方方面面,并在最后给出了Java语言的实现代码。代码虽然简单,但其中原理却非常值得一读。

介绍

大多数用户在不同的网站或应用中使用相同的密码,因此当网站的数据库被盗取,存储的密码也不应该被攻击者获取。与密码学大多数领域一样,需要考虑很多因素;幸运的是,大多数现代编程语言和框架都提供了内置的功能来帮助存储密码,让问题变得简单很多。

本文章提供了与存储密码有关的各个方面的指导。简而言之:

  • 使用Bcrypt。除非你有足够充分的理由不这么做。
  • 设置合理的计算因子(work factor)。
  • 使用盐Salt(现代算法会自动帮你这么做)。
  • 考虑使用胡椒Pepper来提供额外的防御深度(尽管单独使用它无法提供额外的安全特性)。

背景

哈希与加密(Hashing vs Encryption)

哈希和加密是两个经常被混淆或错误使用的术语。它们之间的主要区别在于哈希是单向的(不可能“解密”哈希值并获得原始值),而加密是双向的。

在几乎所有情况下,都应该对密码进行哈希处理而不是加密,因为这会使攻击者很难或不可能从哈希中获取原始密码。

只有在必须获取原始密码的极端情况下才使用加密。可能需要这样做的一些情景是:

  • 如果应用程序需要使用密码对不支持单独登录(SSO)的外部遗留系统进行身份验证。
  • 如果需要从密码中检索单个字符。

能够解密密码意为着严重的安全风险,因此应进行充分的风险评估。在可能的情况下,应使用其它代替方案,以避免需要以加密形式存储密码。

本文章专注于密码哈希,有关加密密码的更多指导,请参阅Cryptographic Storage Cheat Sheet

攻击者如何破解密码哈希

尽管无法“解密”密码散列以获得原始密码,但是在某些情况下可以“破解”哈希。基本步骤是:

  • 选择一个常用的密码(例如“password”)。
  • 计算输入的哈希值。
  • 将其与目标哈希值进行比较。

使用所有候选密码重复此过程,直到找到匹配项。有很多不同的方法可用于选择候选密码,包括:

  • 暴力破解(尝试所有可能的密码)。
  • 常用密码的词典或单词表。
  • 从其它被攻击站点获得的密码列表。
  • Markov chainsPRINCE之类的更复杂的算法。
  • 常用模式(例如“1个大写字母,6个小写字母,1个数字”)。

哈希概念(Hashing Concepts)

加盐(Salting)

每个salt都是独一无二的,随机生成的字符串,在哈希过程中添加到每个密码中。由于salt对于每个用户而言都是唯一的,因此攻击者对每一个密码都必须使用相应的salt进行破解,而不能一次性破解整个数据库的密码。这使得破解大量哈希变得非常困难,所需时间与哈希数量成正比增长。

salt还可以防止攻击者使用预先计算的彩虹表。最后,使用salt意味着无法在不破解哈希的情况下确定两个用户是否具有相同的密码,因为即使密码相同,不同的salt也会导致不同的哈希。

Argon2Bcrypt之类的现代哈希算法会自动对密码加salt,因此使用它们时无需其他步骤。 但是,如果您使用的是旧式密码哈希算法,则需要手动实施加盐。执行此操作的基本步骤是:

  • 使用安全随机数生成盐。使用java.security.SecureRandom,而不是java.util.Random()
    • 盐的长度至少应为16个字符。
    • 将盐编码为安全字符集,例如hexadecimalBase64
  • 结合salt和密码。
    • 可以使用简单的拼接或HMAC来完成。
  • 对组合后的密码和salt进行哈希运算。
  • 存储哈希值。

安全随机数的生成方法

加胡椒(Peppering)

除了加salt,还可以加pepper以提供额外的保护。它类似于salt,但有四个主要区别:

  • 所有密码共享pepper,不像盐一样对每个密码都是唯一的。这使得pepper可预测,并可尝试破解密码。pepper的静态特性也“减弱了”哈希抗冲突的能力,而salt则使用独一无二的字符串来扩展长度,提高了哈希抗冲突能力,增加了对哈希函数的输入熵。
  • pepper不存储在数据库中,不像salt一样(但salt也并非总是如此)。
  • pepper并不是使密码破解变得过于困难使攻击变得不可行,这是其它许多密码存储保护措施(如salt)的目标。
  • salt可防止攻击者制作已知密码的彩虹表,但是pepper没有这样的作用。

pepper的目的是防止攻击者仅获得数据库时(例如,如果他们利用SQL注入漏洞或获得数据库的备份)便无法破解任何哈希。

pepper的长度不应少于32个字符,并使用安全随机生成器(CSPRNG)随机生成(如:java.security.SecureRandom)。应该使用安全访问API将它安全地存储在“秘密文件库”中(不能放在应用的配置文件中,因为容易受到SSRF的攻击),或者将pepper存储在硬件安全模块(HSM)中,以获得最佳的安全保障。

pepper的用法和salt类似,在hash之前将papper和密码拼接起来,如hash($pepper . $password)salt可以用拼接的方式,但papper只能以前缀的形式拼接。不要将papper作为后缀,因为这可能会导致诸如与截断和长度扩展攻击有关的漏洞。这些威胁使密码通过验证,因为独一无二的密码永远不会被截断,只有papper会被截断。

备选方案

pepper的另一种可选方案是,照常hash密码然后使用对称加密对hash进行加密,加密后再保存到数据库中。这种加密充当pepper,以不直接影响密码或hash的方式发挥作用。这避免了拼接/前缀方法的已知问题,并且如果您认为pepper已被泄露,在您更换pepper密钥时,它允许密码保持有效。

另一个解决方案是以ID的方式存储pepper。当存储密码时,将关联的pepperID一起保存到数据库中。这样可以做到更换pepper时,不会泄露pepper。当需要更换pepper时,可以使用新的pepperID替换之前的值。这要求应用程序将pepperID与外部存储关联起来。

工作因子(Work Factors)

工作因子是指为每个密码进行hash运算的次数(通常是2^work次)。工作因子的目的是使计算hash更加费时,从而降低了攻击者的破解速度。工作因子通常和hash值存储在一起。

选择工作因子时,需要在安全性和性能之间取得平衡。较高的工作因子将使hash值更难以被攻击者破解,但也会使登录验证的过程变慢。如果工作因子过高,会降低应用程序的性能,并且攻击者还可以通过进行大量的登录尝试来耗尽服务器的CPU性能,对它进行拒绝服务攻击。

理想的工作因子没有黄金法则,它取决于服务器的性能和用户数量。确定最佳工作因子将需要在实际使用的服务器上进行实验。通常,计算哈希值应少于一秒钟,但在流量较高的站点上,则应大大少于此值。

更新工作因子

随着时间的推移,硬件的性能不断提升而价格则不断下降。按照摩尔定律,工作因子每18个月应该加1。

更新工作因子的最常见方法是等用户下次登录时,使用新的工作因子重新hash密码。这意味着不同的密码具有不同的工作因子,并且如果用户不重新登录到应用程序,则可能导致密码hash永远无法升级。某些情况下,删除较旧的密码,要求用户在下次登录时重置密码可能是适当的,以避免存储较旧且安全性较低的密码hash。

在某些情况下,可能不需要原始密码即可提高hash的工作因子,尽管普通的哈希算法(例如Bcrypt和PBKDF2)不支持此功能。

最大密码长度

某些哈希算法(例如Bcrypt)的最大输入长度为72个字符(有报告称有的实现甚至更短,但在撰写本文时尚未确定)。在使用Bcrypt的情况下,最大长度应限制为64个字符,因为它提供了足够的限制,同时仍允许出现字符串终止问题,并且不能揭示应用程序使用了Bcrypt。

此外,由于现代哈希函数在计算上的非常消耗性能,如果用户可以提供非常长的密码,则可能存在拒绝服务漏洞,例如2013年在Django中发布的漏洞。

为了防止这两个问题,应设置最大密码长度。对于Bcrypt,应该是64个字符(由于算法和实现方面的限制),而对于其他算法,则应该在64和128个字符之间。

限制最大密码长度确实会减少密码空间,但限制为64个字符仍然有至少2^420的密码空间,攻击者不可能利用这个进行攻击。因此,这不会明显地降低安全性。

预哈希密码

另一种方法是使用快速算法(例如SHA-256)对用户提供的密码进行预哈希处理,然后使用诸如Bcrypt(即bcrypt(sha256($password)))等更安全的算法对所得哈希值进行hash处理。尽管此方法解决了用户输入任意长度的密码,导致哈希算法较慢的问题,但它还引入了一些漏洞,这些漏洞可能使攻击者更容易破解密码。

如果攻击者能够从两个不同的站点中获取密码哈希,其中一个使用bcrypt(sha256($password))存储密码,另一个使用sha256($password),则攻击者可以使用从第二个站点未经破解的SHA-256哈希作为候选密码尝试尝试从第一个(更安全)站点破解这些哈希。如果在两个站点之间重复使用密码,则攻击者可以有效地剥离Bcrypt层,然后将精力集中在破解更容易的SHA-256哈希上。

使用预哈希时,请确保将第一个hash结果安全地编码为十六进制或base64,因为如果输入包含空,则某些哈希算法(例如Bcrypt)可能会以不良方式运行。

由于这些问题,通常首选的方法是限制最大密码长度。仅在有特定要求的情况下才应执行密码的预哈希处理,并且已经采取了适当的措施来减轻上述问题。

密码哈希算法

现代算法

有许多专门设计用于安全存储密码的现代哈希算法。这意味着它们应该很慢(不像MD5和SHA-1这样设计得很快的算法),并且可以通过更改工作因子来配置它们有多慢。

有三种主要算法:

Argon2id

Argon2是2015年密码哈希竞赛的获胜者。该算法有三种不同版本,应使用Argon2id变体,因为它提供了一种平衡的方法来抵御旁通道攻击和基于GPU的攻击。

Argon2具有三个可以配置的参数,而不是像其它算法只能设置的工作因子,这意味着设置正确的参数更为复杂。如果您无法正确的设置参数,则应该使用Bcrypt之类的更简单算法。

PBKDF2

PBKDF2是NIST推荐的,并且具有经过FIPS-140验证的实现。因此,当需要这些算法时,它应该是首选算法。此外,它在.NET框架中是开箱即用的,因此通常在ASP.NET应用程序中使用。

PBKDF2可以基于多种不同的哈希算法与HMAC一起使用。HMAC-SHA-256被NIST广泛支持并推荐使用。

PBKDF2的工作因子通过循环计算实现,循环次数应至少为10,000(在安全性要求更高的环境中,100,000可能更合适)。

Bcrypt

Bcrypt是得到最广泛支持的算法,除非有对PBKDF2有特殊的需求或有调优Argon2的知识,否则它应该是默认选择。

Bcrypt的默认工作因子是10,除非在较旧或性能较差的系统上运行,否则通常应将其提高到12。

传统算法

在某些情况下,通常是由于使用传统语言或环境而无法使用现代哈希算法。如果可能,应使用第三方库来提供这些算法。但是,如果只能使用旧算法,例如MD5和SHA-1,则应该采取许多措施来提高存储密码的安全性。

  • 使用最强大的算法(SHA-512 > SHA-256 > SHA-1 > MD5)。
  • pepper
  • 为每个密码使用唯一的salt,使用安全随机数生成器生成。
  • 进行多次hash计算(至少10,000次,如果硬件性能好的话,应该更多)。

应该强调的是,这些步骤都不如使用现代哈希算法好,仅在没有其它选项可用的情况下才应采用这种方法。

升级旧式哈希

对于使用安全性较低的哈希算法(例如MD5或SHA-1)构建的遗留程序,应将这些哈希值升级为更现代和安全的哈希值。当用户下一次输入密码(如登录)时,使用新算法重新加密它。最好使用户的当前密码过期,并要求他们输入一个新密码,以使用户密码的哈希值对攻击者而言不再有用。

但是,这种方法意味着旧的(安全性较低)密码哈希将存储在数据库中,直到用户下次登录为止,并且可能会无限期地存储。有两种主要方法可以解决此问题。

一种方法是使长时间不活动的用户失效并删除其密码哈希值,并要求他们重置密码以再次登录。尽管安全,但是这种方法并不是特别用户友好,并且使大量用户的密码过期可能会给支持人员造成麻烦,或者可能被用户认为是违规的行为。但是,如果在登录时进行密码哈希升级与删除旧密码哈希之间存在合理的间隔时间,则大多数活动用户应该已经更改了密码。

另一种方法是将现有的密码哈希值作为更安全算法的输入。例如,如果应用程序最初将密码存储为md5($password),则可以轻松将其升级为bcrypt(md5($password))。以这种方式对哈希进行分层可以不用知道原始密码。但是如“预哈希密码”部分所述,这种方式会使哈希更容易破解。因此,下次用户登录时,应直接使用用户密码进行哈希,用于替换旧的密码哈希值。

自定义算法

编写自定义的加密代码(例如哈希算法)确实很困难,并且绝不应该在学术活动之外进行。使用未知算法或定制算法可能带来的任何潜在好处将大大地被其中存在的弱点所削弱。

不要这样做(Do not do this)。

Java哈希实现(Hashing Java)

代码范例

public static byte[] hashPassword( final char[] password, final byte[] salt, final int iterations, final int keyLength ) {
    try {
        SecretKeyFactory skf = SecretKeyFactory.getInstance( "PBKDF2WithHmacSHA512" );
        PBEKeySpec spec = new PBEKeySpec( password, salt, iterations, keyLength );
        SecretKey key = skf.generateSecret( spec );
        byte[] res = key.getEncoded( );
        return res;
    } catch( NoSuchAlgorithmException | InvalidKeySpecException e ) {
        throw new RuntimeException( e );
    }
}

代码指南

password和salt参数是数组,是由hashPassword函数所决定的。使用后应清除敏感数据(将数组元素设置为零)。

该示例使用Password Based Key Derivation Function 2 (PBKDF2),如前所述。

salt参数应该是随机数,对每个密码都是唯一的。长度至少应为32个字节。请记住,将salt与密码哈希值存储在一起!

iterations参数指定PBKDF2执行多少次hash计算。值越高越安全。您需要在实际运行的硬件上进行实验。找到需要半秒才能执行加密运算的值。请记住,将iterations值与密码哈希值存储在一起!

256的keyLength是安全的。(生成的是32位的哈希值)

如果示例代码抛出NoSuchAlgorithmException异常,可以将PBKDF2WithHmacSHA512替换为PBKDF2WithHmacSHA1。两者都足以胜任这项任务,但是当人们看到“SHA1”时,您可能会受到批评(在PBKDF2之外使用SHA1是不安全的)。

自JDK 1.4版以来,SecretKeyFactory和PBEKeySpec类已成为Java SE的一部分。

本文翻译自:

QQ咨询
电话
微信
微信扫码咨询