用于密码哈希的 HMACSHA512 与 Rfc2898DeriveBytes

信息安全 密码 哈希 hmac
2021-08-16 14:41:49

我们目前在 .net 中使用HMACSHA512,具有128Char(64byte)验证密钥salt 是 64 char 随机生成的 字符串我们在数据库上为散列的 base64 字符串结果分配了 2048 长度。这将是一个面向公众的网站。 这种方法是否合理,还是应该改为另一种方法,例如 Rfc2898DeriveBytes?

 public string HashEncode(string password, string salt, string validationKey) {
        byte[] hashKey = BosUtilites.HexStringToByteArray(validationKey);
        var sha512 = new HMACSHA512(hashKey);
        var hashInput = BosUtilites.StringToByteArray(password + salt);
        byte[] hash = sha512.ComputeHash(hashInput);
        return Convert.ToBase64String(hash);
    }

 public string GenerateSimpleSalt(int Size = 64) {
        var alphaSet = new char[64]; // use 62 for strict alpha... that random generator for alphas only
        //nicer results with set length * int i = 256. But still produces excellent random results.
        //alphaset plus 2.  Reduce to 62 if alpha requried
        alphaSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890#=".ToCharArray();
        var tempSB = GenerateRandomString(Size, alphaSet);
        return tempSB.ToString();
    }

    public StringBuilder GenerateRandomString(int Size, char[] alphaSet) {
        using (var crypto = new RNGCryptoServiceProvider()) {
            var bytes = new byte[Size];
            crypto.GetBytes(bytes); //get a bucket of very random bytes
            var tempSB = new StringBuilder(Size);
            foreach (var b in bytes) { // use b , a random from 0-255 as the index to our source array. Just mod on length set
                tempSB.Append(alphaSet[b%(alphaSet.Length)]);
            }

            return tempSB;
        }

EDIT2:如果有人通过谷歌找到这个,我已经包括了 在工作站测试中的平均样本是 300 毫秒。这在登录期间不应该太明显。并且不再需要验证密钥。这是一种解脱:-)

 SCrypt package installed via nuget. and rfc2898 PBKDF2 changed to be large number or iterations but only 20bytes output.  SAme CPU time.

默认情况下,新密码以 SCRYPT 编码,

  <package id="CryptSharpOfficial" version="2.0.0.0" targetFramework="net451" />
  // save salt, hash algorithm used and resulting encoding on user record
  public string PasswordEncode(string password, byte[] salt, HashAlgorithm hashAlgorithm ) {
        switch (hashAlgorithm) {
            case HashAlgorithm.PBKDF2:
                    var deriver2898 = new Rfc2898DeriveBytes(password, salt,<Use a number around 50K>); // approx 300msecs on workstation
                    byte[] hash = deriver2898.GetBytes(20); // 
                    return Convert.ToBase64String(hash);
            case HashAlgorithm.Scrypt:
                var key = Encoding.UTF8.GetBytes(password);
                byte[] hashScrypt =  SCrypt.ComputeDerivedKey(key: key, salt: salt, 
                                    cost: 65536, // must be a power of 2 !, on PC, singlethread this is approx 1 sec
                                    blockSize: 8, 
                                    parallel: 1,
                                    maxThreads: 1, 
                                    derivedKeyLength: 128);

                    return Convert.ToBase64String(hashScrypt);
            default:
                throw new ArgumentOutOfRangeException("hashAlgorithm");
        }
    }
4个回答

Rfc2898DeriveBytes实现PBKDF2:一个将密码(带盐)转换为任意长度字节序列的函数。PBKDF2 通常用于密码散列(即计算和存储足以验证密码的值),因为它具有密码散列函数所需的特性:可配置的慢度

这些特征是必需的,因为密码很弱:它们适合人脑。因此,它们很容易被穷举搜索:一般来说,枚举人类用户会想出并记住的大多数密码是可行的。攻击假设攻击者获得了盐和散列密码的副本,然后将在他自己的机器上“尝试密码”。这称为离线字典攻击

在您的情况下,您有第三个元素:验证键它是一把钥匙,即所谓的秘密。如果攻击者可以获取盐和散列密码但不能获取验证密钥,那么他就无法在自己的机器上执行字典攻击;在这些条件下(验证密钥是保密的,并且验证算法是健壮的——HMAC/SHA-512 很好),不需要 PBKDF2 的可配置慢度。这种使用密钥的验证有时被称为“peppering”。

但是请注意,当我们假设攻击者可以获取散列密码的副本时,假设密钥仍然没有被他卑鄙的目光玷污就变成了一个微妙的问题。这取决于上下文。大多数 SQL 注入攻击将能够读取所有数据库的一部分,但不能读取机器上的其余文件。尽管如此,您的服务器必须能够以某种方式在没有人为干预的情况下启动和启动,因此验证密钥位于磁盘上的某个位置。窃取整个磁盘(或备份磁带......)的攻击者也将获得验证密钥——此时您又回到了对可配置速度的需求。

一般来说,我会推荐 PBKDF2(Rfc2898DeriveBytes在 .NET 中也称为)而不是自定义构造,尽管我必须说您似乎正确使用了 HMAC(自制构造很少达到那种正确程度)。如果您坚持拥有“验证密钥”(并且您准备承担密钥管理的程序开销,例如该密钥的特殊备份),那么我建议使用 PBKDF2,然后在 PBKDF2 输出上应用 HMAC。

有关密码散列的详细讨论,请参见此答案

我正在具体回应原始问题中吸取的经验教训的编辑。

以 1000 次迭代调用 Rfc2898DeriveBytes。使用 1024 字节输出。幸运的是,Db 中的密码大小设计为 2k。工作站测试的平均样本约为 300 毫秒

快速总结:如果您喜欢当前的 CPU 负载和 Rfc2898DeriveBytes,则将 1000 次迭代和 1024 字节输出更改为 52000 次迭代和 20 字节输出(20 字节是 SHA-1 的本机输出,即 .NET 4.5 Rfc2898DeriveBytes是根据)。

原因的解释,包括参考资料:

为避免让攻击者占上风,请勿使用 PBKDF2/RFC2898/PKCS#5(或 HMAC,在 PBKDF2 等人内部使用),其输出大小大于所用哈希函数的本机输出。由于 .NET 的实现(从 4.5 开始)硬编码 SHA-1,因此您应该使用最多 160 位的输出,而不是 8192 位的输出!

原因是我们参考RFC2898 规范,如果输出大小(dkLen,即 Derived Key Length)大于本机哈希输出大小(hLen,即 Hash Length)。在第 9 页,我们看到

第 2 步:“设 l 为派生密钥中 hLen-octet 块的数量,向上取整”

第三步:“T_1 = F (P, S, c, 1) , T_2 = F (P, S, c, 2) , ... T_l = F (P, S, c, l) ,”

在第 10 页:第 4 步:“DK = T_1 || T_2 || ... || T_l<0..r-1>”其中 DK 是派生密钥(PBKDF2 输出)和 || 是连接运算符。

因此,我们可以看到,对于您的 8192 位密钥和 .NET 的 HMAC-SHA-1,我们有 8192/160 = 51.2 个块,并且 PBKDF2 需要 CEIL(51.2) = 52 个块,即 T_1 到 T_52 (l = 52) . 规范引用 .2 与完整块不同的情况超出了本讨论的范围(提示:计算完整结果后的截断)。

因此,您在密码上运行了一组 1000 次迭代,总共 52 次,并连接输出。因此,对于一个密码,您实际上正在运行 52000 次迭代!

聪明的攻击者只会运行 1000 次迭代,并将他们的 160 位结果与 8192 位输出的前 160 位进行比较——如果失败,那就是错误的猜测,继续。如果它成功了,那几乎可以肯定是一个成功的猜测(攻击)。

因此,你在 CPU 上运行了 52,000 次迭代,而攻击者在他们拥有的任何东西上运行了 1,000 次迭代(可能是几个 GPU,首先每个 GPU 的 SHA-1 速度都大大超过了你的 CPU);您为攻击者提供了超过硬件优势的 52:1 优势。

值得庆幸的是,一个好的 PBKDF2 函数很容易调整;只需将输出长度更改为 160 位,将迭代次数更改为 52,000,您将使用相同数量的 CPU 时间,存储更小的密钥,并使任何攻击者的成本增加 52 倍,而您自己却没有运行时成本!

如果您想用 GPU 进一步削弱攻击者,您可能希望切换到 PBKDF2-HMAC-SHA-512(或 scrypt 或 bcrypt)和 64 字节或更小的输出大小(SHA-512 的本机输出大小),这显着减少了当前(2014 年初)GPU 相对于 CPU 的优势数量,因为 64 位指令在 CPU 中而不是 GPU 中。然而,这在 .NET 4.5 中并不原生可用。

  • 但是,@Jither 创建了一个很好的示例,将 PBKDF2-HMAC-SHA256、PBKDF2-HMAC-SHA384、PBKDF2-HMAC-SHA512 等功能添加到 .NET,并且我在其中包含了一个带有一组合理测试向量的变体我的 Github 存储库供参考。

有关 1Password 中实际设计缺陷的另一个参考,请参阅此 Hashcat 论坛主题-“对于 PBKDF2-HMAC-SHA1 的每次迭代,您调用 4 次 SHA1 转换。但这只是为了生成 160 位密钥。要生成需要 320 位密钥,你调用它 8 次。”

PS 如果您愿意,对于小的设计更改,如果您将输出存储在 VARBINARY 或 BINARY 列中而不是执行 Base64 编码,则可以节省更多的数据库空间。

PPS 即在您的编辑中更改测试代码如下(2000*52 = 104000);请注意,您的文本说 1000 并且您的测试列出了 2000,因此文本到文本和代码到代码,所以它是。

//var hash = libCrypto.HashEncode2(password, salt, 2000);
var hash = libCrypto.HashEncode2(password, salt, 104000);
// skip down into HashEncode2
//byte[] hash = deriver.GetBytes(1024);
byte[] hash = deriver.GetBytes(20);

SHA2 系列不是密码存储的好选择。它比 md5 好得多,但实际上你应该使用 bcrypt(或 scrypt!)。

RNGCryptoServiceProvider是一个很好的熵源。理想情况下,盐不是以 64 为基数,而是以 256 为基数,就像整个字节一样。为了更好地理解这一点,您需要知道彩虹表是如何生成的。彩虹表生成的输入需要一个键空间。例如,可以生成一条彩虹来查找:长度为 7-12 个字符的字母数字符号。现在要破解这个提议的盐方案,攻击者必须生成一个长度为 71-76 个字符的字母数字符号来补偿 64 个字符的盐(很大)。使盐成为一个完整的字节,将大大增加彩虹表必须耗尽的键空间。

我认为,问题的 RandomString 函数中存在一个错误(除了几个小的语法问题)。只要 Length 参数不是 256 的因数,它就有偏差。如果 Length 为 62,正如代码建议的选项,'a' 和 'b' 将比任何其他字符更频繁地出现(而不是一个不错的偶数 1所有字符的 /62 分布,a 和 b 的出现频率是其他字符的两倍,即 2/64。其余 60 个字符的出现频率为 1/64)。

因为存在偏差,所以产生相同盐的机会增加。这不太可能,但它就在那里。不过,就目前而言,如果您使用全部 64 个字符,那就没问题了。