在数据库中加密/哈希明文密码

62

我继承了一个 Web 应用程序,发现它将超过 30 万个用户名/密码以明文形式存储在 SQL Server 数据库中。我认识到这是非常糟糕的事情™。

我知道我必须更新登录和密码更新流程以进行加密/解密,并对系统的其余部分影响最小,您会建议什么是从数据库中删除明文密码的最佳方法?

感谢任何帮助。

编辑:如果我表述不清楚,我想问的是您加密/哈希密码的步骤,而不是具体的加密/哈希方法。

我应该只需要:

  1. 备份数据库
  2. 更新登录/更新密码代码
  3. 在下班后遍历用户表中所有记录,对密码进行哈希并替换每个密码
  4. 测试以确保用户仍然可以登录/更新密码

我担心的更多是用户数量如此之多,因此我要确保操作正确。


15
非常糟糕的事情(商标) :) - xan
5
你是否获取了Reddit用户数据库?;-) - John Topley
1
可能是[安全密码哈希]的重复问题。 (https://dev59.com/TnI-5IYBdhLWcg3weoFO) - Gilles 'SO- stop being evil'
2
@Gilles - 我不确定这个拥有超过34,000次浏览的7年问题如何成为一个拥有不到5,000次浏览的6年问题的重复,但显然你认为它是。我同意如果今天在SO上发布问题,我不会问这个,但是在所有其他编程相关的Stack Exchange网站存在之前,这个问题已经被提出过了。这个问题本身更多关于从纯文本迁移到更安全密码的过程,而不是加密/散列方法的具体实现。 - Jonathan S.
@Gilles - 我提到其他SE网站是为了重申这更多是一个流程/程序问题,如果当我最初提问时程序员堆栈交换网站存在,我可能会在那里问。我仍然不相信这是一个重复的问题,因为我并没有询问应该使用哪种哈希算法。当前被接受的答案是当时讨论从明文密码迁移到更安全实现的最佳答案。 - Jonathan S.
显示剩余4条评论
14个回答

50

编辑(2016年):按照偏好顺序使用Argon2, scrypt, bcryptPBKDF2。尽可能使用大的减速系数,根据您的情况而定。使用经过审核的现有实现。确保使用正确的盐(虽然您使用的库应该为您确保这一点)。


当您对密码进行哈希时,请不要使用明文MD5
使用PBKDF2,这基本上意味着使用随机盐来防止彩虹表攻击,并迭代(重新哈希)足够的次数以减慢哈希速度-不要让您的应用程序花费太长时间,但足够让攻击者暴力破解大量不同的密码。
从文档中:
  • 至少迭代1000次,最好更多-计时实现以查看可行的迭代次数。
  • 8字节(64位)的盐足以,而且随机数不需要安全(盐未加密,我们不担心有人会猜测它)。
  • 在哈希时应用盐的好方法是使用HMAC和您喜欢的哈希算法,使用密码作为HMAC密钥,将盐用作要哈希的文本(请参见文档的this section)。

Python中的示例实现,使用SHA-256作为安全哈希:

编辑:正如Eli Collins所提到的,这不是PBKDF2实现。 您应该更喜欢坚持标准的实现,例如PassLib

from hashlib import sha256
from hmac import HMAC
import random

def random_bytes(num_bytes):
  return "".join(chr(random.randrange(256)) for i in xrange(num_bytes))

def pbkdf_sha256(password, salt, iterations):
  result = password
  for i in xrange(iterations):
    result = HMAC(result, salt, sha256).digest() # use HMAC to apply the salt
  return result

NUM_ITERATIONS = 5000
def hash_password(plain_password):
  salt = random_bytes(8) # 64 bits
  
  hashed_password = pbkdf_sha256(plain_password, salt, NUM_ITERATIONS)

  # return the salt and hashed password, encoded in base64 and split with ","
  return salt.encode("base64").strip() + "," + hashed_password.encode("base64").strip()

def check_password(saved_password_entry, plain_password):
  salt, hashed_password = saved_password_entry.split(",")
  salt = salt.decode("base64")
  hashed_password = hashed_password.decode("base64")

  return hashed_password == pbkdf_sha256(plain_password, salt, NUM_ITERATIONS)

password_entry = hash_password("mysecret")
print password_entry # will print, for example: 8Y1ZO8Y1pi4=,r7Acg5iRiZ/x4QwFLhPMjASESxesoIcdJRSDkqWYfaA=
check_password(password_entry, "mysecret") # returns True

我一直认为对哈希进行哈希不是一个好的做法,因为每次迭代哈希冲突的可能性都会增加。但是(salt+hash)这种哈希方式是否可以规避这个问题呢?毕竟字符数量并不是很多... - Henrik Paul
1
你说得对,重新哈希可能会减少搜索空间(盐不起作用),但这对基于密码的加密来说是无关紧要的。为了达到此哈希的256位搜索空间,您需要一个完全随机的密码,长度为40个字符,包括所有可用的键盘字符(log2(94^40))。 - orip
2
人们应该意识到,这段代码并没有实现PBKDF2算法;相反,它是旧的PBKDF1函数的非标准变体,修改为使用PRF(在本例中为HMAC-SHA256)。请参见rfc2898以获取两个kdf的参考实现。虽然这个算法可能不是不安全的,但它既不与PBKDF1也不与PBKDF2兼容,也没有给出其确切行为的同样安全审查 - 我担心它将HMAC应用于固定盐,并改变密码 - 这可能会削弱HMAC的安全性。 - Eli Collins
@Eli:并不完全反对,因为PBKDF2可以创建任意长度的密钥,而这段代码则不能。当然,在密码安全方案中,这没有任何意义。但是<a href="http://tools.ietf.org/html/rfc2898#appendix-B.1.1">您链接到的rfc中的文本</a>明确提到将密码用作HMAC的“密钥”,将盐用作HMAC的“文本”,这正是此示例代码的意图。 - orip
2
@orip:加密学通常不是一个“差不多就行”的领域;特别是如果人们将其误认为是PBKDF2实现,只有在后来发现输出与现有代码/数据不匹配时才会发现。的确,如果修复了代码中盐/密码漏洞,它将更符合附录的要求,但这仅仅是描述如何在PBKDF2中使用HMAC;而不是PBKDf2的工作原理。除了省略可变密钥长度部分外,上面的代码最重要的问题是完全省略了PBKDF2中F()函数的XOR部分——这是其预像抗性的核心。 - Eli Collins
只是补充一下 - 对于大多数算法讨论,个别实现可能会有所不同,只要它们达到了预期的效果。但是PBKDf2是一个经过精心设计的算法,具有测试向量,指定了确切的行为; 而且它处于一个问题空间中,微小的变化可能意味着安全性的严重降低。在大多数其他情况下,我甚至认为这些都不值得一提 :) - Eli Collins

38

基本策略是使用密钥派生函数将密码与某些盐“哈希”。盐和哈希结果存储在数据库中。当用户输入密码时,盐和他们的输入以相同的方式进行哈希,然后与存储的值进行比较。如果匹配,则用户已通过身份验证。

魔鬼在于细节。首先,很多取决于所选择的哈希算法。像基于哈希的消息验证码的密钥派生算法PBKDF2使得查找一个输入(在这种情况下是密码)能够生成给定输出(攻击者在数据库中找到的内容)变得“计算上不可行”。

预先计算的字典攻击使用哈希输出到密码的预先计算索引或字典。哈希化是慢的(或者应该是这样),因此攻击者一次性哈希所有可能的密码,并以这样的方式存储结果,即可以查找相应的密码。这是空间和时间的经典权衡。由于密码列表可能很大,因此有方法来调整权衡(如彩虹表),以使攻击者放弃一点速度以节省大量空间。

通过使用“加密盐”来防止预计算攻击。这是用密码散列的某些数据。 它不需要是秘密的,它只需要对于给定的密码来说是不可预测的。对于每个盐值,攻击者需要一个新的字典。如果使用一位字节的盐,则攻击者需要 256 份其字典的副本,每个都由不同的盐生成。首先,他将使用盐查找正确的字典,然后他将使用哈希输出查找可用的密码。但如果您添加 4 个字节呢?现在他需要 40 亿份字典副本。通过使用足够大的盐,可以排除字典攻击。在实践中,来自加密质量随机数发生器的 8 至 16 字节数据是一个很好的盐。

如果预先计算不可行,攻击者必须在每次尝试中计算哈希值。现在找到一个密码需要多长时间完全取决于计算候选密码的哈希值需要花费多长时间。哈希函数的迭代会增加这个时间。迭代次数通常是密钥派生函数的参数;现在许多移动设备使用10,000到20,000次迭代,而服务器可能使用100,000次或更多次迭代(bcrypt算法使用"cost factor"来衡量所需时间的对数度量单位)。


就哈希生成而言,速度慢与否取决于哈希的使用方式。对于密码存储来说,这是一种理想的特性。但对于消息认证来说,可能并非如此(尤其是对于网络数据包进行认证时)。 - Michael Burr
好的观点;缓慢对于保护免受离线攻击非常重要。在网络协议中,即使使用非常快的哈希算法,中间人攻击者也可能没有足够的时间找到碰撞点,只要它没有被破解。 - erickson
1
一个措辞得当的密码加密和相关术语解释,谢谢。 - JYelton

19

我认为你需要在数据库中添加一个加密密码的列,然后对所有记录运行批处理作业,获取当前密码,加密它(如其他人所提到的哈希函数md5是相当标准的 编辑:但不应该单独使用-有关好的讨论请参见其他答案),将其存储在新列中并检查所有操作是否顺利进行。

然后,您需要在前端更新用户登录时哈希用户输入的密码,并将其与存储的哈希值进行验证,而不是检查明文与明文之间的匹配。

在删除纯文本密码之前,为了确保没有任何奇怪的事情发生,我认为留下两个列是明智的。

还要记住,每当访问密码时,代码都必须更改,例如密码更改/提示请求。 当然,您将失去通过电子邮件发送忘记密码的功能,但这并不是坏事。 相反,您必须使用重置密码系统。

编辑: 最后一点,您可能希望避免我在测试安全登录网站上的第一次尝试中犯的错误:

在处理用户密码时,请考虑哈希计算发生的位置。 在我的情况下,哈希是由运行在Web服务器上的PHP代码计算的,但密码以明文形式从用户的计算机传输到网页! 在我工作的环境中,这还可以(几乎)接受,因为它已经在https系统内部了(大学网络)。 但是,在现实世界中,我想您会希望在密码离开用户系统之前对其进行哈希处理,使用JavaScript等,并将哈希传输到您的站点。


2
如果他们关闭了 JavaScript,会发生什么? - Malfist
26
不能在用户的设备上对密码进行哈希。哈希必须由可信系统完成。(否则,任何窃取了密码表副本的人都可以将哈希发送给您;哈希已变成密码。) 但是,是的,这确实需要像 HTTPS 这样的安全传输方式从用户处传输。 - erickson
@Malfist:这很快就会成为一个历史性问题。非常少的人禁用js。在这种情况下,我会将未经哈希处理的密码发送到服务器,并在服务器端代码中进行适应。这只是一种不太理想的备选方案。 - Lucas Oman
@erickson:对于那些特别偏执的人,你可以将密码双重哈希存储在数据库中,并从客户端接受单哈希密码。 - Lucas Oman
4
要么哈希,要么不哈希。在客户端进行哈希是没有意义的。使用哈希解决的威胁模型是密码数据库的泄露。如果这不是一个问题,请不要哈希。否则,你不能使用由用户产生的哈希(或从被盗数据库复制品中读取的哈希)进行身份验证。 - erickson
显示剩余2条评论

4

3
我认为您应该执行以下操作:
  1. 创建一个名为HASHED_PASSWORD或类似的新列。
  2. 修改您的代码,使其检查两个列。
  3. 逐步将密码从非哈希表迁移到哈希表中。例如,当用户登录时,自动将其密码迁移到哈希列并删除未哈希版本。所有新注册的用户都将有哈希密码。
  4. 在非工作时间,您可以运行一个脚本,每次迁移n个用户。
  5. 当您没有更多未哈希密码时,可以删除旧密码列(取决于您使用的数据库,您可能无法这样做)。此外,您可以删除处理旧密码的代码。
  6. 完成!

2

正如其他人所提到的,如果可能的话,您不想解密。标准最佳实践是使用单向哈希算法进行加密,然后当用户登录时将其密码进行哈希处理以进行比较。

否则,您将不得不使用强大的加密来进行加密,然后再进行解密。我只会建议在政治原因非常强烈的情况下使用(例如,您的用户习惯于能够调用帮助台检索其密码,并且您从高层领导面临强大压力不要更改这一点)。在这种情况下,我会从加密开始,然后开始构建一个过渡到哈希处理的商业案例。


2
为了进行身份验证,您应避免使用可逆加密存储密码,即应仅存储密码哈希值,并检查用户提供的密码的哈希值是否与您存储的哈希值相同。然而,这种方法有一个缺点:如果攻击者获得了您的密码库数据库,它易受彩虹表攻击。
您应该做的是存储预先选择(且保密)盐值和密码的哈希值。即,将盐和密码连接起来,对结果进行哈希并存储此哈希值。在进行身份验证时,也进行同样的操作-将您的盐值和用户提供的密码连接起来,进行哈希,然后检查是否相等。这使彩虹表攻击变得不可行。
当然,如果用户通过网络传输密码(例如,如果您正在开发Web或客户端服务器应用程序),则不应在明文中发送密码。因此,不要存储哈希(盐+密码),而应存储并根据哈希(盐+哈希(密码))进行检查,并让客户端对用户提供的密码进行预处理,并将其发送到网络上。这样也可以保护用户的密码,防止用户(像许多人一样)将同一密码重复使用于多个目的。

1
盐不需要保密,保持其机密性是很麻烦的。 - erickson
2
此外,要明确的是-盐应该对于每个实例都是不同随机的。不是预先选择一次并用于所有哈希函数。 - Michael Burr
Mike在原则上当然是正确的,但并不总是可能每次更改盐(取决于应用程序的具体情况),在这种情况下必须保密盐。 - Mihai Limbășan

1
  • 使用类似MD5的加密算法,将其编码为十六进制字符串
  • 您需要一个盐; 在您的情况下,用户名可以用作盐(它必须是唯一的,用户名应该是可用的最独特值;-)
  • 使用旧密码字段存储MD5,但标记MD5(即“MD5:687A878....”),以便旧(明文)和新(MD5)密码可以共存
  • 更改登录过程以根据是否存在MD5验证MD5,否则根据明文密码验证
  • 更改“更改密码”和“新用户”功能,仅创建MD5密码
  • 现在,您可以运行转换批处理作业,这可能需要很长时间
  • 运行转换后,删除遗留支持

通常为每个用户使用随机盐并将其与散列密码一起存储是很常见的做法。 - Michael Haren
对于用户来说未知的东西会使得盐更安全。也许是内部userID,或者像Michael建议的那样,一个特别创建的盐值。如果你正在使用一个公开可用的唯一盐,比如用户名,为了加强安全性,你应该考虑再加上一个常量盐。 - rmeador
据我所知,加盐的目的是为了防止字典攻击(预先计算流行密码的哈希值并将其与用户进行比较)。盐始终可见,不是秘密。那么为什么不使用用户名呢?因为它已经被知道,并保证是唯一的。 - mfx
用户名如果只考虑单个系统,那么并不是一个糟糕的盐值。但是对于像专制政府这样的攻击者来说,制作最常见用户名的字典可能会增加他们入侵多个站点的机会。因此最好选择和存储一个随机的盐值。 - erickson

1

步骤1:向数据库添加加密字段。

步骤2:更改代码,使得当密码被更改时,它会更新两个字段,但登录仍然使用旧字段。

步骤3:运行脚本以填充所有新字段。

步骤4:更改代码,使得登录使用新字段,并停止更新旧字段的密码更改。

步骤5:从数据库中删除未加密的密码。

这样可以在不影响最终用户的情况下完成转换。

另外: 我会给新的数据库字段命名为与密码完全无关的名称,比如“LastSessionID”或类似的无聊名称。然后,不要删除密码字段,只需用随机数据的哈希值填充即可。这样,如果您的数据库被攻击,他们可以花费所有时间尝试解密“密码”字段。

这可能实际上并没有实现任何东西,但想象有人坐在那里试图解密毫无价值的信息是很有趣的。


关于第二步在流程中早期需要的观点很好。我也分享您对虚拟密码字段的喜爱 :) - Kristen

0

与所有安全决策一样,都存在权衡。如果您对密码进行哈希处理,这可能是最简单的方法,但您将无法提供返回原始密码的密码检索功能,也无法让您的员工查找某人的密码以便访问其账户。

您可以使用对称加密,但它也有自己的安全缺陷。(如果您的服务器被攻击,对称加密密钥也可能会被泄露)。

您可以使用公钥加密,并在一个独立的机器上运行密码检索/客户服务,该机器将私钥与Web应用程序隔离存储。这是最安全的方法,但需要两台机器架构,可能还需要防火墙。


2
员工无法查找用户密码并非缺陷,而是一种特性。支持密码检索不是一种权衡,而是一种放弃。 - erickson
我认为这是一个不幸的绝对主义立场。这实际上取决于由该密码保护的信息的安全价值。 - davidcl
这还进一步取决于所涉及的员工规模和可信度。在小型组织中,密码查找比大型组织更有意义。 - davidcl
2
你永远不知道一个特定的密码保护着什么;要预期用户会在你的玩具网站上重复使用他们银行账号的密码。即使你的员工是可信的(即使只有你自己),你也无法排除外部攻击者可能获取到你的密码数据库的可能性。 - erickson
2
如果工作人员有合法的需要访问另一个用户的账户,那么这种能力应该被内置到系统中,而无需工作人员以用户身份登录。此外,“密码找回”系统的需求可以被“密码重置”系统所取代。 - Michael Burr

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接