最近在项目中看到一段代码,惊掉了下巴。 csharp public override async Task PasswordSignInAsync(string username, string password, bool rememberMe){ var user = await UserManager.FindByNameAsync(username); if (user is null) { var length = Math.Min(GetInt32(password.Length, password.Length + 50), PasswordValidation.MaximumLength); var lengthByteCount = System.Text.Encoding.Unicode.GetByteCount(new string(密, length)); var randomPassword = System.Text.Encoding.Unicode.GetString(GetBytes(lengthByteCount));

    _userManager.PasswordHasher.HashPassword(new ApplicationUser(), randomPassword);
    Logger.LogUserInvalidUsername();
    await _eventService.RaiseAsync(new UserLoginFailureEvent(username, InvalidCredentialsAuditError, 错误的用户名));

    return SignInResult.Failed;
}

return await PasswordSignInAsync(user, password, rememberMe);

}

就是说,在登录过程中,先检查用户名,如果用户名不存在,一般不就直接返回错误吗?而这段代码,却是生成了一个密码,并一本正经地做哈希计算。一查 Microsoft.AspNetCore.Identity.PasswordHasher 使用的还是 PBKDF2 哈希算法,运行时非常耗费计算机资源。我心想这是什么神奇代码?!是屎山的一种吗?
无独有偶,在 Spring Security 中也发现类似的代码,比如 DaoAuthenticationProvider中有这样一段: java public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { // 首先定义了USER_NOT_FOUND_PASSWORD常量,这个是当用户查找失败时的默认密码; private static final String USER_NOT_FOUND_PASSWORD = userNotFoundPassword;

protected final UserDetails retrieveUser(String username, UsernamePassword AuthenticationToken authentication) throws AuthenticationException {    
    // 首先会调用prepareTimingAttackProtection方法,该方法的作用是使用PasswordEncoder对常量USER_NOT_FOUND_PASSWORD进行加密,将加密结果保存在userNotFoundEncoded Password变量中。   
    prepareTimingAttackProtection();       
    try {           
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        if (loadedUser == null) {               
            throw new InternalAuthenticationServiceException(UserDetailsService returned null, which is an interface contract violation);           
        }           
    
        return loadedUser;
    } catch (UsernameNotFoundException ex) {
        // 当根据用户名查找用户时,如果抛出了UsernameNotFoundException异常,则调用mitigateAgainstTimingAttack方法进行密码比对。
        mitigateAgainstTimingAttack(authent

ication);
throw ex; } // ... }

 private void prepareTimingAttackProtection(

) { if (this.userNotFoundEncodedPassword == null) {
this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PAS SWORD);
}
}

private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
    if (authentication.getCredentials() != 

null) { String presentedPassword = authentication.getCredentials().toString();
this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
} } } // ...

也就是说,首先定义了一个 userNotFoundEncodedPassword变量,作为默认密码。当用户名在系统中找不到时,就用它和登录请求传过来的用户密码进行密码比对。这是一个一开始就注定要失败的密码比对,那么为什么还要进行比对呢?
原来呀,这是为了避免旁道攻击(Side-channel attack)。
TIP
在密码学中,旁道攻击又被称为侧信道攻击、边信道攻击。这种攻击方式不是使用暴力破解或者基于加密算法的弱点,而是基于从密码系统的物理实现中获取信息,比如时间、功率消耗、电磁泄漏等等,以为进一步破解系统提供参考信息。
如果根据用户名查找用户失败,就直接抛出异常而不进行密码比对,那么黑客经过大量的测试,就会发现有的请求耗费时间明显小于其他请求,那么进而可以得出该请求的用户名是一个不存在的用户名(因为用户名不存在,所以不需要密码比对,
进而节省时间),这样就可以获取到系统信息。为了避免这一问题,所以当用户查找失败时,也会调用mitigateAgainstTimingAttack方法进行密码比对,这样就可以迷惑黑客。
TIP
你是不是也疑惑过,连苹果等这种国际大厂商的网站,输入完密码点击登录时,页面也要转很久才会跳转至登录态?这就是因为他们使用了 AOWF,而且工作因子设置得很高,从而导致了登录验证的过程变得很慢。

如何防止用户名枚举攻击

一般的登录验证做法是,如果用户名不存在,直接返回错误;如果用户名存在,再将用户名输入的密码进行哈希,再和数据库中存储的哈希过的密码进行比较,然后返回结果。更安全的做法是,不仅需要哈希密码,还会对密码加“盐”来对抗彩虹表攻击。不过,在这样实施的方案下,即使黑客无法攻破用户的密码,但是,黑客仍然能够拿到有价值的信息,即可以探测出哪些用户名在系统中存在,哪些不存在。
要为攻击者猜测系统账号增加障碍,就是当用户名不存在时,不要立即返回结果,而是要模拟一个正常的登录流程,包括密码哈希和“盐”的计算,这样,无论用户名是否正确,都要经过一段时间才能得到响应,从而无法快速枚举用户。
要注意的是,不能使用 Task.Delay 或者 Thread.Sleep 这样的简单模拟延迟的方法,因为同样的延迟模式会给黑客一些明显的提示,从而可以通过对比响应时间的延迟来得知用户名是否存在。然而要简单地随机化延迟时间也是不够的,因为黑客可以通过多次尝试来计算出平均延迟时间,从而得知用户名是否存在。要做到不暴露用户名不存在的特征,更优雅的做法是使用真正的密码哈希过程。比如生成一个符合系统要求的随机密码,然后再使用自适应单向函数进行哈希和“盐”的计算,这样,无论用户名是否存在,都要经过一段时间才能得到响应,并且不会和真实用户登录时产生不同的对外特征,从而使得攻击者无法快速枚举用户。

自适应单身函数

SHA-256 这样的安全哈希算法,也是一种单向函数,但是和这里的自适应单向函数有所不同。通过使用 SHA-256 这样的安全哈希算法,可以有效地防止数据泄露,即使系统被攻破,也只是存储的密文被泄漏而已。相比于泄露明文,密文被泄露对于攻击者来说,破解的难度要大得多。
尽管如此,在获取到泄露的密文之后,黑客总是可以在线下进行暴力破解。要防御这种情况,只有一种办法:那就是选择使用资源密集型的哈希算法 ,在进行哈希计算时,耗费的资源越多越好,从而使得暴力破解的成本高到破解成功后能够得到的好处。
自适应单向函数(Adaptive One-Way Function,简称 AOWF)是一种单向函数,它和普通的单向函数的区别在于,它在进行哈希计算时,会有意地占用大量系统资源(如CPU、内存等)。这样,假如攻击者在尝试不同的用户名密码组合时,整个系统的响应就会变得非常缓慢,从而有效地防止了暴力破解攻击。不像 MD5 和 SHA-1 被设计成快速的哈希算法,AOWF 被设计成了慢速的哈希算法,并且需要多慢都可以通过调节工作因子来实现。
在 Spring Security 中,可以使用 bcrypt、pbkdf2、scrypt、argon2id、sha256、sha512 等编码器。其中,bcrypt、pbkdf2、scrypt、argon2 都是 AOWF,而 sha256、sha512 则是普通的单向函数。Spring Security 提供了一个名为 DelegatingPasswordEncoder 的类,它可以根据不同的编码器 ID 来选择不同的编码器。网站也不需要对外隐藏其使用的哈希算法,如果你使用了现代化的密码哈希算法,并且做了合理的参数配置,那么对外公开所使用的哈希算法是没有问题的。
TIP
存储密码的最佳实践:

  1. 使用 Argon2id 算法,则配置最少19MiB内存,迭代次数设置为 2,并行度设置为 1。
  2. 如果 Argon2id 不可用,那么尝试使用 scrypt 算法,并将最小的CPU/内存成本参数设置为 217,将最小的块大小设置为 8(1024字节)以及将并行度设置为 1。
  3. 对于使用 bcrypt 的遗留系统,将工作因子(迭代次数)设置为 10 或者更高,并将密码大小限制在 72 个字符(72字节)以内。
  4. 如果需要做到 FIPS-140 合规,就需要使用 PBKDF2 算法,将工作因子(迭代次数)设置为 600000 或者更高,并在内部使用 HMAC-SHA-256 哈希算法。

工作因子

工作因子是指哈希算法在对每个密码进行哈希计算过程中所执行的迭代次数(一般实际上是 2工作因子 次迭代)。工作因子越大,哈希计算所需的时间就越长,从而使得暴力破解的成本越高。工作因子一般会存储在哈希输出值里。
当选择工作因子时,就不得不在安全与性能之间做一个权衡。越高的工作因子,破解越困难,但同时也会让验证登录行为的的过程变得越慢。

升级工作因子

工作因子的存在有一个很大的优势,就是随着时间的推移,硬件会变得更强并且更便宜,所以就可以适时不断提高工作因子。对于密码验证的场景,一个常见做法是,当用户下次登录时,使用新的工作因子重新哈希他们的密码。这意味着不同的哈希值有不同的工作因子,也意味着如果有一些用户永远不再登录系统的话,他们的哈希值永远不会被升级。不同的应用有不同的需求,有一种合理的需求是清除老旧的哈希值,并要求这些用户再次登录系统时进行密码重置,从而使得他们的密码被重新哈希。这样做可以避免在系统里存储老旧的不那么安全的哈希值。

Argon2id

Argon2 在 2015 年的密码哈希竞赛中取得了冠军。它有 3 个版本,我们应该使用 Argon2id 这个版本,因为它在防止旁道攻击与基于 GPU 的攻击中取得了很好的平衡。
和其他的算法不一样,Argon2id 不仅仅只有一个简单的工作因子,相反,它有 3 个不同的参数可供配置。Argon2id 应该使用下面的配置之一做为最小的基准,该配置包含了最小内存(缩写为 m)、最小迭代次数(缩写为 t)和最小并行度(缩写为 p)。

  • m=47104 (46 MiB), t=1, p=1 (不要在 Argon2i 中使用该配置)
  • m=19456 (19 MiB), t=2, p=1 (不要在 Argon2i 中使用该配置)
  • m=12288 (12 MiB), t=3, p=1
  • m=9216 (9 MiB), t=4, p=1
  • m=7168 (7 MiB), t=5, p=1

以上几个配置从防御效果上说是等价的,只是在 CPU 和内存的使用上有不同的权衡。

scrypt

scrypt 是 Colin Percival 创建的基于密码的密钥派生函数。Argon2id 应该是密码哈希的首选,但如果因为各种原因导致不能使用该算法时,就可以考虑 scrypt。
TIP
在密码学中,密钥派生函数(英语:Key derivation function,简称:KDF)使用伪随机函数从诸如主密钥或密码的秘密值中派生出一个或多个密钥。KDF可用于将密钥扩展为更长的密钥或获取所需格式的密钥,例如将作为迪菲-赫尔曼密钥交换结果的组元素转换为用于高级加密标准(AES)的对称密钥。用于密钥派生的伪随机函数最常见的示例是密码散列函数。
和 Argon2id 一样,scrypt 也有 3 个参数可供配置,分别是 CPU/内存成本参数(缩写为 N)、块大小(缩写为 r)和并行度(缩写为 p)。scrypt 应该使用下面的配置之一做为最小的基准,该配置包含了最小的 CPU/内存成本参数(N)、最小的块大小(r)和最小的并行度(p)。

  • N=217 (128 MiB), r=8 (1024 bytes), p=1
  • N=216 (64 MiB), r=8 (1024 bytes), p=2
  • N=215 (32 MiB), r=8 (1024 bytes), p=3
  • N=214 (16 MiB), r=8 (1024 bytes), p=5
  • N=213 (8 MiB), r=8 (1024 bytes), p=10

以上几个配置从防御效果上说是等价的,只是在 CPU 和内存的使用上有不同的权衡。

bcrypt

对于遗留系统来说,或者为了实现 FIPS-140 合规要求而使用 PBKDF2 算法的场合,bcrypt 算法是绝佳选择。bcrypt 是一种基于 Blowfish 加密算法的密码哈希函数,它的工作因子(迭代次数)可以设置为验证服务器的性能所允许的最大值,但最小应该设置为 10。
输入限制
对于大多数的 bcrypt 实现来说,所支持的最大输入长度是 72 个字符(72 字节)。如果要使用 bcrypt,就需要限制密码长度为 72 字符;如果使用的 bcrypt 版本的输入限制比 72 还要小,那么要对应地限制密码长度为更少的字符。
预哈希密码
如果不想限制密码的长度,那么可以使用快速哈希算法(比如前面介绍的 SHA-256)来对用户的密码输入进行预哈希,再对预哈希结果应用 bcrypt(比如像这样:bcrypt(base64(hmac-sha256(data: $password, key: $pepper)), $salt, $cost) )。尽管这样做很常见,但还是很危险,因为在将 bcrypt 和其他哈希算法结合使用时,很容易出现一些奇怪的问题所以应该避免采用这种方式。

PBKDF2

PBKDF2 是 NIST 推荐的并且具有经过 FIPS-140 合规性验证实现的哈希函数。如果有相关的硬性要求,那就需要选择使用该算法。
PBKDF2 要求选择一个内部哈希算法(比如 HMAC 或者其他哈希算法的变种)。HMAC-SHA-256 被广泛支持,且被 NIST 推荐。
PBKDF2 的工作因子通过迭代次数来控制,基于所使用的内部哈希算法,该值应该被设置为相应的不同的值。

  • PBKDF2-HMAC-SHA1: 1,300,000 次迭代
  • PBKDF2-HMAC-SHA256: 600,000 次迭代
  • PBKDF2-HMAC-SHA512: 210,000 次迭代

最开头的例子,就是使用了 PBKDF2 密码哈希算法。

彩蛋:哈希与加密的区别

经常有人说密码应该加密存储,实际上更严格地说,密码是哈希存储。哈希和加密是计算机安全学中最重要的
安全机制。哈希与加密都能够为敏感数据提供保护,但它们有一个明显的区别:

  • 哈希是一种单向函数(比如,几乎不可能将一个哈希值“解密”而得到原始明文)。哈希用在密码验证上是非常适合的。就算攻击者拿到了哈希后的密码,也不能用它登录系统。除非是比如为了兼容某些不支持 OIDC 的验证系统,从而需要明文密码的情况而使用加密存储外,其他的情况下,密码都应该是被哈希而不是加密存储的。
  • 加密是一种双向函数,即原始明文是可以被解密的。加密用在数据传输上是非常适合的。如果攻击者拿到了加密后的数据,但是没有密钥,那么他也无法解密数据。比如对于用户的地址信息,就需要加密存储,同时在个人资料页面上显示明文(如果在这种场景下使用哈希,会导致系统不可用)。