如何在 Java 中安全地存储密码

2025-06-08

如何在 Java 中安全地存储密码

本文的先前版本混淆了“哈希”过程和“加密”过程。值得注意的是,哈希和加密虽然类似,但却是两个不同的过程。


哈希算法涉及多对一的转换,其中给定的输入被映射到一个(通常大小固定且更短的)输出,而该输出并非特定于该输入的唯一输出。换句话说,当对大量输入值(不同的输入映射到相同的输出)运行哈希算法时,很可能会发生冲突。由于哈希算法的性质,哈希算法是一个不可逆的过程。这个SO答案很好地概述了哈希算法和加密之间的区别,并提供了一个很好的例子来说明为什么哈希算法实际上可能是不可逆的。

作为一个数学示例,考虑模函数(又名 modulus 又名 mod )(此处表示为由 Donald Knuth 定义):

a % n = mod(a, n) = a - n * floor(a/n)
Enter fullscreen mode Exit fullscreen mode

mod 函数的简单解释是,它是整数除法的余数:

7 % 2 = 1
4 % 3 = 1
33 % 16 = 1
Enter fullscreen mode Exit fullscreen mode

请注意,上面三个例子即使输入不同,输出也相同。模函数是不可逆的,因为在应用它时会丢失信息。即使给定其中一个输入和输出,也无法计算出另一个输入值。你只能猜测,直到猜对为止。

哈希算法利用了诸如此类的不可逆函数(以及位移位等),并且经常重复多次,从而极大地增加了所需的猜测次数。哈希的目标是使解码原始信息的计算成本极其高昂。通常情况下,暴力破解哈希算法(通过尽可能快地尝试多种可能的输入)比尝试“逆转”哈希算法来解码哈希信息更容易。

即使是暴力破解攻击,一种常用的阻止方法是添加第二条随机信息作为“盐”。这可以防止黑客进行字典攻击,即将常用密码列表映射到其哈希输出——如果黑客知道所使用的哈希算法,并能够访问存储哈希值的数据库,他们就可以使用他们的“字典”映射回原始密码,从而获得这些账户的访问权限。对数据进行加盐意味着黑客不仅需要将特定密码通过哈希算法运行并验证其是否与哈希输出匹配,还必须对每个可能的盐值(通常是几十到几百字节的字符串)重新运行该过程。一个随机的100字节盐值意味着每个可能的密码都必须尝试(100*2^8或)256,000种密码盐组合。

另一种阻止暴力攻击的方法是简单地要求你的算法运行一段时间。如果你的算法运行时间仅为 2 秒,并使用上面提到的 100 字节盐值,那么仅仅为了得到一个潜在密码,就需要将近 6 天的时间来尝试所有可能的盐值字符串。这也是为什么哈希算法通常需要迭代数千次的原因之一。为了生成安全的哈希值,请确保每次迭代时都重新引入盐值,否则你将面临更多不必要的哈希碰撞(参见上面的 SO 链接)。


与哈希算法不同,加密算法始终是一对一且可逆的(通过解密)。因此,特定的输入始终会产生特定的输出,并且只有该特定输入才会产生特定的输出。与哈希算法(生成固定长度的哈希值)不同,加密算法会产生可变长度的输出。一个好的加密算法应该产生与随机噪声难以区分的输出,这样输出中的模式就无法被利用来解码。

当存储的数据需要在某个时刻提取时,应该使用加密。例如,消息应用程序可能会在传输数据之前对其进行加密,但接收方收到后需要将其解密回明文,以便读取。请注意,密码并非如此:如果您的密码和存储的盐值生成了存储在数据库中的哈希码,那么您输入的密码很可能是正确的。因此,对密码进行哈希处理并重新计算哈希码比存储可能被解密的加密密码更安全。

但是如果加密信息可以解密,它又如何保证安全呢?加密和解密的标准方法有两种:对称密钥加密和非对称密钥加密。

对称密钥加密意味着使用相同的密钥来加密和解密数据。一个常用的解释加密的比喻是邮寄上锁的包裹。如果你在一个盒子上锁并寄给我,而我有一把可以打开这把锁的钥匙,那么我就能轻松地打开它并读取里面的信息。我可以给你回信,你也可以用同一把钥匙打开另一边的盒子。

在盒子运输过程中,除非拥有与我们相同的对称密钥,否则任何人都无法打开盒子阅读信息。因此,我们的想法是将密钥保密,不与消息预期接收者以外的任何人共享。然而,由于存在两个对称私钥,如果有人设法窃取(或创建)其中一个私钥的副本,你我之间未来所有的通信都将受到损害。换句话说,我们彼此信任,相信彼此会保护密钥的安全。

非对称密钥加密略有不同,因为你和我都有挂锁和钥匙,但我们在邮寄中互相发送的第一个包裹应该是我们未锁定的挂锁:

然后,你可以写一条消息,并用我的挂锁将消息锁在一个盒子里。从那时起,唯一能打开盒子的人就是我,用我的私钥。当我收到你的消息时,我会用你的挂锁将其锁在一个盒子里,然后寄回给你。从那时起,唯一能打开盒子的人就是你,用你的私钥。

这种方法也称为公钥加密,因为在这种情况下,“挂锁”通常被广泛使用。任何想要加密发送给特定收件人的消息的人都可以这样做。与纯文本密码相比,我们更推荐使用公钥加密,因为它可以有效地使密码更长、更难猜测,降低有人“偷看”并窃取密码的可能性,而且通常比反复输入密码要容易得多

显然,这只是对哈希和加密的皮毛的介绍,但我希望它能让你更好地理解两者之间的区别。现在,回到我们定期的节目……


原文(已更新,以便更清晰):


在 Java 中对密码进行哈希处理的过程一开始可能很难理解,但实际上只需要三件事:

  1. 密码
  2. 散列算法
  3. 一些美味的salt

(请注意, Java 中也可以进行加密和解密,但通常不用于密码,因为密码本身不需要恢复。我们只需要检查用户输入的密码是否重新创建了我们保存在数据库中的哈希值。)


基于密码的加密以用户密码为起点生成加密密钥。我们使用单向哈希函数将密码不可逆地转换为固定长度的哈希码,并添加第二个随机字符串作为“盐”,以防止黑客执行字典攻击。字典攻击是指将常用密码列表映射到其哈希输出。如果黑客知道所使用的哈希算法,并能够访问存储哈希码的数据库,他们就可以使用他们的“字典”映射回原始密码,从而访问这些帐户。

我们可以使用 Java 的SecureRandom类简单地生成盐:


import java.security.SecureRandom;
import java.util.Base64;
import java.util.Optional;

  private static final SecureRandom RAND = new SecureRandom();

  public static Optional<String> generateSalt (final int length) {

    if (length < 1) {
      System.err.println("error in generateSalt: length must be > 0");
      return Optional.empty();
    }

    byte[] salt = new byte[length];
    RAND.nextBytes(salt);

    return Optional.of(Base64.getEncoder().encodeToString(salt));
  }
Enter fullscreen mode Exit fullscreen mode

(注意:您也可以使用SecureRandom.getInstanceStrong()获取一个SecureRandom实例,尽管这会引发一个异常,因此需要将其包装在一个块中。)NoSuchAlgorithmExceptiontry{} catch(){}

接下来,我们需要哈希码本身:

import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

  private static final int ITERATIONS = 65536;
  private static final int KEY_LENGTH = 512;
  private static final String ALGORITHM = "PBKDF2WithHmacSHA512";

  public static Optional<String> hashPassword (String password, String salt) {

    char[] chars = password.toCharArray();
    byte[] bytes = salt.getBytes();

    PBEKeySpec spec = new PBEKeySpec(chars, bytes, ITERATIONS, KEY_LENGTH);

    Arrays.fill(chars, Character.MIN_VALUE);

    try {
      SecretKeyFactory fac = SecretKeyFactory.getInstance(ALGORITHM);
      byte[] securePassword = fac.generateSecret(spec).getEncoded();
      return Optional.of(Base64.getEncoder().encodeToString(securePassword));

    } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
      System.err.println("Exception encountered in hashPassword()");
      return Optional.empty();

    } finally {
      spec.clearPassword();
    }
  }
Enter fullscreen mode Exit fullscreen mode

...这里发生了很多事情,所以让我一步一步解释一下:

  public static Optional<String> hashPassword (String password, String salt) {

    char[] chars = password.toCharArray();
    byte[] bytes = salt.getBytes();
Enter fullscreen mode Exit fullscreen mode

首先,我们最终需要将密码作为char[],但用户将其作为 传递String(否则我们如何从用户那里获取密码?),因此我们必须char[]一开始就将其转换为 。盐值也作为 传递,因此String必须将其转换为byte[]。这里假设散列后的密码和盐值将作为字符串写入数据库,因此我们希望在此算法之外生成盐值作为 ,String并将其也作为 传递String

将用户密码保存在 中String非常危险,因为 JavaString的 是不可变的——一旦创建,就无法被覆盖以隐藏用户密码。因此,最好收集密码,对其进行必要的处理,然后立即丢弃对原始密码的引用,String以便对其进行垃圾回收。(您可以建议JVM 使用 来回收死引用System.gc(),但垃圾回收的时间间隔不可预测,因此无法强制执行。)同样,如果我们将密码转换Stringchar[],则在使用完毕后应该清除数组(稍后会详细介绍)。

    PBEKeySpec spec = new PBEKeySpec(chars, bytes, ITERATIONS, KEY_LENGTH);
Enter fullscreen mode Exit fullscreen mode

在这里,我们指定如何生成散列密码。chars是明文密码char[],是转换为的bytes是我们应该执行散列算法的次数,是生成的加密密钥的所需长度(以位为单位)。Stringbyte[]ITERATIONSKEY_LENGTH

执行哈希算法时,我们会将明文密码和盐值作为伪随机输出字符串。迭代哈希算法会重复该过程多次,并将第一次哈希的输出作为第二次哈希的输入。这也称为密钥拉伸,它会大大增加执行暴力破解攻击所需的时间(较大的盐值字符串也会使此类攻击更加困难)。需要注意的是,虽然增加迭代次数会增加哈希密码所需的时间,从而降低数据库遭受暴力破解攻击的几率,但由于需要额外的处理时间,它也可能使您容易受到 DoS 攻击

最终哈希密码的长度受所用算法的限制。例如,“PBKDF2WithHmacSHA1”允许哈希值最长为 160 位,而“PBKDF2WithHmacSHA512”哈希值最长可达 512 位。KEY_LENGTH指定比所选算法的最大密钥长度更长的哈希值不会使密钥长度超过其指定的最大值,实际上还会降低算法的速度

最后,请注意,它spec保存了有关算法、原始明文密码等所有信息。我们完成后一定要删除所有这些。

    Arrays.fill(chars, Character.MIN_VALUE);
Enter fullscreen mode Exit fullscreen mode

数组现在处理完毕chars,可以清空它了。这里,我们将数组的所有元素设置为\000(空字符)。

    try {
      SecretKeyFactory fac = SecretKeyFactory.getInstance(ALGORITHM);
      byte[] securePassword = fac.generateSecret(spec).getEncoded();
      return Optional.of(Base64.getEncoder().encodeToString(securePassword));

    } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
      System.err.println("Exception encountered in hashPassword()");
      return Optional.empty();

    } finally {
      spec.clearPassword();
    }
Enter fullscreen mode Exit fullscreen mode

我们的hashPassword()方法在此代码块结束try{} catch(){}。在此代码块中,我们首先获取之前定义的算法(“PBKDF2WithHmacSHA512”),然后根据 中列出的规范,使用该算法对明文密码进行哈希处理specgenerateSecret()返回一个SecretKey对象,它是“加密密钥的不透明表示”,这意味着它只包含哈希后的密码,不包含其他任何身份信息。我们使用getEncoded()来获取哈希后的密码byte[],并将其保存为securePassword

如果一切顺利,我们会将其byte[]以 base-64 编码(因此它仅由可打印的 ASCII 字符组成)并将其返回为String。我们这样做是为了将散列密码作为字符串保存在数据库中,而不会出现任何编码问题。

Exceptions如果在加密过程中有任何,我们将返回一个空的Optional。否则,我们将通过清除 中的密码来完成该方法spec。现在,此方法中不再有任何对原始明文密码的引用。(请注意,无论 和在任何前面的或块中执行任何操作之前finally是否存在,该块都会执行,因此像这样将其放在末尾是安全的——密码将从 中清除。)Exceptionreturntrycatchspec

最后要做的就是编写一个小方法,用于判断当使用相同的盐值时,给定的明文密码是否生成哈希密码。换句话说,我们需要一个函数来告诉我们输入的密码是否正确:

  public static boolean verifyPassword (String password, String key, String salt) {
    Optional<String> optEncrypted = hashPassword(password, salt);
    if (!optEncrypted.isPresent()) return false;
    return optEncrypted.get().equals(key);
  }
Enter fullscreen mode Exit fullscreen mode

上述代码使用原始salt字符串和所需的明文password来生成一个哈希密码,并将其与之前生成的哈希值进行比较。仅当正确且重新哈希明文密码时没有错误时,key此方法才会返回。truepassword

好了,现在这些都准备好了,让我们来测试一下吧! (注意:我在一个名为的包中的一个名为的实用程序类中定义了这些方法。)PasswordUtilswatson

jshell> import watson.*

jshell> String salt = PasswordUtils.generateSalt(512).get()
salt ==> "DARMFcJcJDeNMmNMLkZN4rSnHV2OQPDd27yi5fYQ77r2vKTa ... Wt9QZog0wtkx8DQYEAOOwQVs="

jshell> String password = "Of Salesmen!"
password ==> "Of Salesmen!"

jshell> String key = PasswordUtils.hashPassword(password, salt).get()
key ==> "djaaKTM/+X14XZ6rxjN68l3Zx4+5WGkJo3nAs7KzjISiT6aa ... sN5DcmOeMfhqMGCNxq6TIhg=="

jshell> PasswordUtils.verifyPassword("Of Salesmen!", key, salt)
$5 ==> true

jshell> PasswordUtils.verifyPassword("By-Tor! And the Snow Dog!", key, salt)
$6 ==> false
Enter fullscreen mode Exit fullscreen mode

效果真棒!快去哈希吧!

鏂囩珷鏉ユ簮锛�https://dev.to/awwsmm/how-to-encrypt-a-password-in-java-42dh
PREV
在 Windows 上安装和运行 Hadoop 和 Spark 在 Windows 上安装和运行 Hadoop 和 Spark 获取软件 设置环境变量 配置 Hadoop 补丁 Hadoop 启动 HDFS 测试 Hadoop 和 Spark
NEXT
我如何开始进行编码演讲?