我该如何在Java中进行密码哈希?

200
我需要为存储在数据库中的密码进行哈希。如何在Java中实现?
我希望将明文密码与随机盐相结合,然后将盐和哈希密码存储在数据库中。
然后,当用户想要登录时,我可以获取他们提交的密码,添加他们帐户信息中的随机盐,对其进行哈希,并查看它是否等于存储在他们帐户信息中的哈希密码。

最好使用SHA哈希函数族。http://en.wikipedia.org/wiki/MD5(虽然没有完美的解决方案) - YGL
11
现在使用GPU攻击变得如此便宜,所以SHA家族算法实际上已经不再是一种安全的密码哈希方法(速度太快),即使加盐也不行。应该使用bcrypt、scrypt或PBKDF2算法。请注意,这并非重新编排。 - Eran Medan
11
为什么这个问题被关闭了?这是一个关于真实工程问题的提问,答案非常有价值。提问者并不是在寻找一个程序库,而是在询问如何解决这个工程问题。 - stackoverflowuser2010
12
太棒了。这个问题有52个赞,但有人决定将其关闭,认为它“不相关”。 - stackoverflowuser2010
1
是的,我之前在 Meta 上发布过关于关闭问题的帖子,但遭到了很严厉的批评。 - Chris Dutrow
9
这个问题应该重新开放。这是一个关于如何编写程序来解决描述的问题(密码认证),并提供了简短的代码解决方案。看到触发词“library”并不足以自动关闭问题;他并没有要求库的建议,而是在询问如何散列密码。编辑:好了,修正了。 - erickson
14个回答

168

你可以使用Java运行时内置的一个设施来实现这一点。Java 6中的SunJCE支持PBKDF2,这是一个用于密码哈希的好算法。

SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
KeySpec spec = new PBEKeySpec("password".toCharArray(), salt, 65536, 128);
SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
byte[] hash = f.generateSecret(spec).getEncoded();
Base64.Encoder enc = Base64.getEncoder();
System.out.printf("salt: %s%n", enc.encodeToString(salt));
System.out.printf("hash: %s%n", enc.encodeToString(hash));

这是一个实用的类,您可以用它进行PBKDF2密码验证:

import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.util.Arrays;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

/**
 * Hash passwords for storage, and test passwords against password tokens.
 * 
 * Instances of this class can be used concurrently by multiple threads.
 *  
 * @author erickson
 * @see <a href="https://dev59.com/SnE85IYBdhLWcg3wRBRU#2861125">StackOverflow</a>
 */
public final class PasswordAuthentication
{

  /**
   * Each token produced by this class uses this identifier as a prefix.
   */
  public static final String ID = "$31$";

  /**
   * The minimum recommended cost, used by default
   */
  public static final int DEFAULT_COST = 16;

  private static final String ALGORITHM = "PBKDF2WithHmacSHA1";

  private static final int SIZE = 128;

  private static final Pattern layout = Pattern.compile("\\$31\\$(\\d\\d?)\\$(.{43})");

  private final SecureRandom random;

  private final int cost;

  public PasswordAuthentication()
  {
    this(DEFAULT_COST);
  }

  /**
   * Create a password manager with a specified cost
   * 
   * @param cost the exponential computational cost of hashing a password, 0 to 30
   */
  public PasswordAuthentication(int cost)
  {
    iterations(cost); /* Validate cost */
    this.cost = cost;
    this.random = new SecureRandom();
  }

  private static int iterations(int cost)
  {
    if ((cost < 0) || (cost > 30))
      throw new IllegalArgumentException("cost: " + cost);
    return 1 << cost;
  }

  /**
   * Hash a password for storage.
   * 
   * @return a secure authentication token to be stored for later authentication 
   */
  public String hash(char[] password)
  {
    byte[] salt = new byte[SIZE / 8];
    random.nextBytes(salt);
    byte[] dk = pbkdf2(password, salt, 1 << cost);
    byte[] hash = new byte[salt.length + dk.length];
    System.arraycopy(salt, 0, hash, 0, salt.length);
    System.arraycopy(dk, 0, hash, salt.length, dk.length);
    Base64.Encoder enc = Base64.getUrlEncoder().withoutPadding();
    return ID + cost + '$' + enc.encodeToString(hash);
  }

  /**
   * Authenticate with a password and a stored password token.
   * 
   * @return true if the password and token match
   */
  public boolean authenticate(char[] password, String token)
  {
    Matcher m = layout.matcher(token);
    if (!m.matches())
      throw new IllegalArgumentException("Invalid token format");
    int iterations = iterations(Integer.parseInt(m.group(1)));
    byte[] hash = Base64.getUrlDecoder().decode(m.group(2));
    byte[] salt = Arrays.copyOfRange(hash, 0, SIZE / 8);
    byte[] check = pbkdf2(password, salt, iterations);
    int zero = 0;
    for (int idx = 0; idx < check.length; ++idx)
      zero |= hash[salt.length + idx] ^ check[idx];
    return zero == 0;
  }

  private static byte[] pbkdf2(char[] password, byte[] salt, int iterations)
  {
    KeySpec spec = new PBEKeySpec(password, salt, iterations, SIZE);
    try {
      SecretKeyFactory f = SecretKeyFactory.getInstance(ALGORITHM);
      return f.generateSecret(spec).getEncoded();
    }
    catch (NoSuchAlgorithmException ex) {
      throw new IllegalStateException("Missing algorithm: " + ALGORITHM, ex);
    }
    catch (InvalidKeySpecException ex) {
      throw new IllegalStateException("Invalid SecretKeyFactory", ex);
    }
  }

  /**
   * Hash a password in an immutable {@code String}. 
   * 
   * <p>Passwords should be stored in a {@code char[]} so that it can be filled 
   * with zeros after use instead of lingering on the heap and elsewhere.
   * 
   * @deprecated Use {@link #hash(char[])} instead
   */
  @Deprecated
  public String hash(String password)
  {
    return hash(password.toCharArray());
  }

  /**
   * Authenticate with a password in an immutable {@code String} and a stored 
   * password token. 
   * 
   * @deprecated Use {@link #authenticate(char[],String)} instead.
   * @see #hash(String)
   */
  @Deprecated
  public boolean authenticate(String password, String token)
  {
    return authenticate(password.toCharArray(), token);
  }

}

12
在使用BigInteger进行字节到十六进制转换时,你可能需要小心一些:前导零会被删除。虽然这对于快速调试来说是可以接受的,但我曾经见过因此在生产代码中出现过错误。 - Thomas Pornin
24
@thomas-pornin的观点强调了我们需要一个而不是一个只有基本功能的代码块。可怕的是,被接受的答案没有回答这个如此重要的主题上的问题。 - Nilzor
10
从Java 8开始使用PBKDF2WithHmacSHA512算法,它更加强大。 - iwan.z
2
@erickson 很抱歉挖起这篇帖子,但我花了一些时间分析这段代码。我想知道为什么在方法authenticate(char[] password, String token)中你将if改成了|=运算符?这样做有什么好处吗?看起来唯一的影响就是更长的执行时间,或者我漏掉了什么? - The Tosters
5
@TheTosters 是的,如果输入错误密码,执行时间会更长;具体来说,错误密码将花费与正确密码相同的时间。这可以防止时序攻击,尽管我承认在这种情况下我想不出实际利用这种漏洞的方法。但你不应该偷懒。仅因为我看不到它,不意味着更加狡猾的人看不到。 - erickson
显示剩余21条评论

30

BCrypt是一个非常好的库,而且有一个它的Java端口


12

您可以使用Spring Security Crypto(仅有2个可选的编译依赖项),支持PBKDF2BCryptSCryptArgon2密码加密。

Argon2PasswordEncoder argon2PasswordEncoder = new Argon2PasswordEncoder();
String aCryptedPassword = argon2PasswordEncoder.encode("password");
boolean passwordIsValid = argon2PasswordEncoder.matches("password", aCryptedPassword);
SCryptPasswordEncoder sCryptPasswordEncoder = new SCryptPasswordEncoder();
String sCryptedPassword = sCryptPasswordEncoder.encode("password");
boolean passwordIsValid = sCryptPasswordEncoder.matches("password", sCryptedPassword);
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String bCryptedPassword = bCryptPasswordEncoder.encode("password");
boolean passwordIsValid = bCryptPasswordEncoder.matches("password", bCryptedPassword);
Pbkdf2PasswordEncoder pbkdf2PasswordEncoder = new Pbkdf2PasswordEncoder();
String pbkdf2CryptedPassword = pbkdf2PasswordEncoder.encode("password");
boolean passwordIsValid = pbkdf2PasswordEncoder.matches("password", pbkdf2CryptedPassword);

9
您可以使用MessageDigest计算哈希值,但从安全角度来看这是错误的。哈希值不应用于存储密码,因为它们很容易被破解。
您应该使用另一种算法,如bcrypt、PBKDF2和scrypt来存储密码。详情请参见此处

3
如何在登录时对密码进行哈希处理而不将盐值存储在数据库中? - ZZ Coder
9
使用用户名作为盐并不是致命的缺陷,但是使用来自密码学随机数生成器的盐要好得多。在数据库中存储盐绝对没有问题,因为盐并不是秘密。 - erickson
1
用户名和电子邮件地址也会存储在数据库中,不是吗? - Chris Dutrow
14
将用户名(或其他ID,如电子邮件)用作盐的问题在于,如果您要更改该ID,则必须让用户设置新密码。 - Lawrence Dol
不好的想法,你需要一个盐和工作因素。这个“协议”中两者都缺失了。 - Maarten Bodewes
显示剩余3条评论

7

那其实就是我一直在使用的。但既然我们决定不使用Shiro,就有些担心为了仅仅一个包而必须包含整个Shiro库的低效率问题。 - Chris Dutrow
我不知道是否有一个仅由密码哈希实用程序组成的库。如果依赖关系是一个问题,那么你最好自己编写。erickson的答案对我来说看起来很不错。或者,如果您宁愿以安全方式使用SHA,则可以从我引用的OWASP链接中复制代码。 - laz

6
除了其他答案中提到的bcrypt和PBKDF2之外,我建议您看一下scrypt
不推荐使用MD5和SHA-1,因为它们相对较快,因此可以使用“每小时租用”分布式计算(例如EC2)或现代高端GPU以相对较低的成本和合理的时间使用暴力破解/字典攻击来“破解”密码。
如果必须使用它们,则至少将算法迭代预定义的重要次数(1000+)。
更多信息请参见:https://security.stackexchange.com/questions/211/how-to-securely-hash-passwords,以及http://codahale.com/how-to-safely-store-a-password/(批评SHA族、MD5等用于密码散列目的),以及http://www.unlimitednovelty.com/2012/03/dont-use-bcrypt.html(批评bcrypt并推荐scrypt和PBKDF2)。

6
完全同意Erickson的观点,PBKDF2是解决方案。
如果您没有该选项,或者只需要使用哈希,那么Apache Commons DigestUtils比编写正确的JCE代码要容易得多: https://commons.apache.org/proper/commons-codec/apidocs/org/apache/commons/codec/digest/DigestUtils.html 如果使用哈希,请选择sha256或sha512。此页面对密码处理和哈希处理有很好的建议(请注意,它不推荐用哈希处理密码): http://www.daemonology.net/blog/2009-06-11-cryptographic-right-answers.html

值得注意的是,SHA512并不比SHA256更好(在这个目的上)仅仅因为数值更大。 - Azsgy

6

虽然已经提到了NIST推荐的PBKDF2, 但我想指出,从2013年到2015年有一场公开的密码哈希比赛。最终,Argon2被选为推荐使用的密码哈希函数。

原始(本地C)库有一个相当广泛采用的Java绑定可供使用。

在平均使用情况下,无论您选择PBKDF2还是Argon2,从安全角度来看都不重要。如果您具有强大的安全要求,请考虑在评估中使用Argon2。

有关密码哈希函数安全性的更多信息,请参见security.se


@zaph 我编辑了答案,使其更加客观。请注意,NIST的建议可能并不总是最佳选择(例如,请参见此处),当然,这也适用于其他任何在其他地方推荐的东西。因此,我认为这个答案对这个问题提供了价值。 - Qw3ry

5
截至2020年,使用最可靠的密码哈希算法,最有可能优化其强度,考虑到任何硬件,是Argon2idArgon2i,但不包括其Spring实现。
PBKDF2标准包括BCRYPT算法块密码的CPU贪婪/计算昂贵特性,并添加其流密码功能。 PBKDF2被内存指数上涨的SCRYPT压倒,然后是抵御侧信道攻击的Argon2
Argon2提供了必要的校准工具,以查找给定目标哈希时间和所使用的硬件的优化强度参数。
  • Argon2i专门用于内存贪婪哈希
  • Argon2d专门用于CPU贪婪哈希
  • Argon2id同时使用两种方法。
内存贪婪哈希将有助于防止GPU破解。

Spring security/Bouncy Castle实现并不是最优化的,相对较弱,攻击者可以利用。 参考:Spring文档 Argon2Scrypt

当前实现使用了Bouncy Castle,它没有利用密码破解器所使用的并行性/优化,因此攻击者和防御者之间存在不必要的不对称。

Java中目前最可信的实现是mkammerer的一个包装JAR /库, 它是C语言编写的官方本地实现的封装。

它编写得非常好,并且易于使用。

嵌入式版本为Linux、Windows和OSX提供本机构建。

作为一个例子,它被jpmorganchase用于其tessera安全项目中,该项目用于保护Quorum,其以太坊加密货币实现。
以下是一个示例:
    final char[] password = "a4e9y2tr0ngAnd7on6P১M°RD".toCharArray();
    byte[] salt = new byte[128];
    new SecureRandom().nextBytes(salt);
    final Argon2Advanced argon2 = Argon2Factory.createAdvanced(Argon2Factory.Argon2Types.ARGON2id);
    byte[] hash = argon2.rawHash(10, 1048576, 4, password, salt);

(查看tessera

在你的POM文件中声明该库:

<dependency>
    <groupId>de.mkammerer</groupId>
    <artifactId>argon2-jvm</artifactId>
    <version>2.7</version>
</dependency>

或者使用Gradle:
compile 'de.mkammerer:argon2-jvm:2.7'

校准可以使用de.mkammerer.argon2.Argon2Helper#findIterations进行。

SCRYPT和Pbkdf2算法也可以通过编写一些简单的基准测试来进行校准,但当前最小安全迭代值将需要更长的哈希时间。


我只想说,这篇文章写得非常好,而且正如你所描述的那样可以直接使用。谢谢。 - user637338

3

3
请记住,在进行密码哈希时,慢速运算更好。你应该使用数千次哈希函数迭代作为“密钥强化”技术。此外,盐值是必须的。 - erickson
是的,但对哈希的哈希进行哈希...更加安全。假设你将“a”哈希为“b”,将“b”哈希为“c”。某人可能会以某种方式(虽然不是唯一的)获得值“b”,但他不太可能获得原始值“a”,因为他不知道你哈希初始值的次数。 - Simon
6
关于密钥强化:盐的存在是为了使预先计算的哈希值无效。但攻击者不必预先计算,他们可以在运行时对字符串进行哈希散列并添加盐,直到找到正确的密钥。但如果您对哈希值进行数千次迭代,攻击者也必须这样做。由于这种情况不经常发生,因此您的服务器不会受到10,000次迭代的太大影响。攻击者需要10,000倍的计算能力。 - zckman
2
@Simon 今天MD5在密码哈希方面已被认为是无用的,因为通过使用GPU暴力破解/字典攻击可以在几秒钟内破解。请查看此链接:http://codahale.com/how-to-safely-store-a-password/ - Eran Medan
@zockman,你已经揭开了一个让我困惑已久的概念,自从我第一次听到盐和哈希在同一个句子中时就感到困惑。现在非常有意义,谢谢。+1 - qualebs
显示剩余3条评论

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