本节书摘来异步社区《Java编码指南:编写安全可靠程序的75条建议》一书中的第1章,第1.13节,作者:【美】Fred Long(弗雷德•朗), Dhruv Mohindra(德鲁•莫欣达), Robert C.Seacord(罗伯特 C.西科德), Dean F.Sutherland(迪恩 F.萨瑟兰), David Svoboda(大卫•斯沃博达),更多章节内容可以访问云栖社区“异步社区”公众号查看。
指南13:使用散列函数存储密码
程序以明文(未加密的文本数据)方式存储密码将会导致密码以多种方式被泄露的风险。尽管程序通常接收到的用户密码是明文的,但是程序应该确保密码不以明文方式存储。
一种可行的限制密码暴露的技术是使用散列函数(hash function),它允许程序间接地比较输入的密码和原始密码,而不需要以明文或可解密的方式存储密码字符串。这种方法最大限度地减少了密码的暴露,而且没有表现出任何实用上的缺点。
加密散列函数
散列函数产生的值是散列值(hash value)或消息摘要(message digest)。散列加密过程在计算上可行,而它的逆向过程在计算上是不可行的。在实践中,明文密码可以编码为散列值,但是散列值不能解码为明文。要比较两个密码是否相等,需要对比它们的散列值是否相等。
总是为需要加密的密码添加一个盐(salt)是一个好的实践。盐是一个唯一的(通常是连续的)或随机生成的数据,与散列值存储在一起。使用盐有助于防止对散列值的蛮干攻击,提供的盐需要足够长,这样才能生成足够的熵(盐值过短不能显著缓解蛮干攻击)。每个密码都应该有自己的盐与之关联。如果一个盐被用于多个密码,那么两个用户将能够看到他们的密码是否相同。
对散列函数和盐的长度的选择,需要在安全性和性能之间做出权衡。通过选择一个更强大的散列函数来增加有效蛮干攻击所需努力的同时,也增加了验证密码所需的时间。虽然增加盐的长度可使蛮干攻击更为困难,但是却需要占用额外的存储空间。
Java的MessageDigest类提供了各种加密散列函数的实现。要避免使用有缺陷的函数,如消息摘要算法(Message-Digest Algorithm,MD5)。安全散列算法SHA-1和SHA-2是由美国国家安全局维护的,目前它们被认为是安全的散列函数。在实践中,许多应用程序使用SHA-256,因为这个散列函数在被认为是安全的同时,还具有合理的性能。
违规代码示例
下面的违规代码示例使用对称密钥算法来加密和解密存储在password.bin中的密码。
public final class Password {
private void setPassword(byte[] pass) throws Exception {
// Arbitrary encryption scheme
bytes[] encrypted = encrypt(pass);
clearArray(pass);
// Encrypted password to password.bin
saveBytes(encrypted,"password.bin");
clearArray(encrypted);
}
boolean checkPassword(byte[] pass) throws Exception {
// Load the encrypted password
byte[] encrypted = loadBytes("password.bin");
byte[] decrypted = decrypt(encrypted);
boolean arraysEqual = Arrays.equal(decrypted, pass);
clearArray(decrypted);
clearArray(pass);
return arraysEqual;
}
private void clearArray(byte[] a) {
for (int i = 0; i < a.length; i++) {
a[i] = 0;
}
}
}```
攻击者可能会解密这个文件,从而发现密码,特别是当攻击者知道程序是如何使用这个密钥和加密方案的时候。任何人都不应该看到明文密码,即使是系统管理员和特权用户也不应该。因此,使用加密只是部分有效地减轻密码泄露的威胁。
####违规代码示例
下面的违规代码示例虽然是通过使用MessageDigest类的SHA-256散列函数对密码的散列值进行的比较,没有使用明文字符串,但是它却使用了字符串来存储密码。
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public final class Password {
private void setPassword(String pass) throws Exception {
byte[] salt = generateSalt(12);
MessageDigest msgDigest = MessageDigest.getInstance("SHA-256");
// Encode the string and salt
byte[] hashVal = msgDigest.digest((pass+salt).getBytes());
saveBytes(salt, "salt.bin");
// Save the hash value to password.bin
saveBytes(hashVal,"password.bin");
}
boolean checkPassword(String pass) throws Exception {
byte[] salt = loadBytes("salt.bin");
MessageDigest msgDigest = MessageDigest.getInstance("SHA-256");
// Encode the string and salt
byte[] hashVal1 = msgDigest.digest((pass+salt).getBytes());
// Load the hash value stored in password.bin
byte[] hashVal2 = loadBytes("password.bin");
return Arrays.equals(hashVal1, hashVal2);
}
private byte[] generateSalt(int n) {
// Generate a random byte array of length n
}
}`
即使攻击者知道程序在存储密码时使用的是SHA-256算法和一个12字节的盐,他也无法从password.bin和salt.bin中获取实际的密码。
尽管这种方式解决了上一个违规代码示例中的解密问题,但是这个程序可能无意中就将密码明文存储在了内存中。Java的字符串对象是不可变的,可以被Java虚拟机复制和存储在其内部。因此,Java缺乏一种用于安全删除存储在字符串中的密码的机制。更多相关信息参见指南1。
合规解决方案
下面的合规解决方案通过使用字节数组来存储密码,解决了上一个违规代码示例中的问题。
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public final class Password {
private void setPassword(byte[] pass) throws Exception {
byte[] salt = generateSalt(12);
byte[] input = appendArrays(pass, salt);
MessageDigest msgDigest = MessageDigest.getInstance("SHA-256");
// Encode the string and salt
byte[] hashVal = msgDigest.digest(input);
clearArray(pass);
clearArray(input);
saveBytes(salt, "salt.bin");
// Save the hash value to password.bin
saveBytes(hashVal,"password.bin");
clearArray(salt);
clearArray(hashVal);
}
boolean checkPassword(byte[] pass) throws Exception {
byte[] salt = loadBytes("salt.bin");
byte[] input = appendArrays(pass, salt);
MessageDigest msgDigest = MessageDigest.getInstance("SHA-256");
// Encode the string and salt
byte[] hashVal1 = msgDigest.digest(input);
clearArray(pass);
clearArray(input);
// Load the hash value stored in password.bin
byte[] hashVal2 = loadBytes("password.bin");
boolean arraysEqual = Arrays.equals(hashVal1, hashVal2);
clearArray(hashVal1);
clearArray(hashVal2);
return arraysEqual;
}
private byte[] generateSalt(int n) {
// Generate a random byte array of length n
}
private byte[] appendArrays(byte[] a, byte[] b) {
// Return a new array of a[] appended to b[]
}
private void clearArray(byte[] a) {
for (int i = 0; i < a.length; i++) {
a[i] = 0;
}
}
}```
在setPassword()和checkPassword()方法,密码的明文表示,在转化为散列值后会被立即擦除。因此,在密码明文被擦除后,攻击者想要获取密码将会变得更加困难。确保数据的完全擦除是极具挑战性的,很可能是与平台有关的,甚至是不可能的——因为需要复制垃圾收集器、进行动态分页,还涉及Java语言层级以下的其他平台相关的操作。
####适用性
没有经过安全散列加密的密码将会暴露给恶意用户。违反这条指南,通常会导致与之相关联的明确的漏洞。