掘金 后端 ( ) • 2024-04-10 20:47

theme: z-blue highlight: a11y-dark

背景:  答主拥有多次P0级密码安全漏洞修复经验,对一些常见的密码漏洞攻防略有心得。看完本文,你将有能力设计出一款既安全又高效率的密码体系。你还将了解到前沿的密码学知识,包括非对称加密, 数字信封, 中间人攻击,用户探测攻击, 彩虹表攻击,暴力攻击,以及极端情况下(私钥泄密且攻入数据库服务器)的密码安全的定量分析。 看完本文,记得赶紧回公司看看你家的密码体系是否足够安全。

前置知识

对称加密

所谓对称加密,就是加密和解密使用的钥匙是同一把。对称加密具有以下特点:

  1. 快速:相比于非对称加密,对称加密算法通常执行速度更快,因为它们使用的是单一密钥进行加密和解密操作。
  2. 简单:对称加密算法的实现相对较简单,所需的计算资源较少。
  3. 安全性依赖于密钥管理:对称加密的安全性取决于密钥的安全性。如果密钥泄露或被未授权方获取,那么加密数据的保密性将被威胁。

常见的对称加密算法包括DES(Data Encryption Standard)、3DES、AES(Advanced Encryption Standard)等。

正是因为对称加密的 安全性依赖于密钥管理(一旦密钥泄露就可以获取到明文), 所以现代密码体系已不再单独使用对称加密。而是结合使用对称加密和非对称加密。

非对称加密

非对称加密指是加密和解密使用的钥匙不一样。客户端加密的时候使用公钥加密, 公钥公开可见; 服务端解密的时候使用私钥解密, 私钥由服务端严格保存,对外不可见。 非对称加解密的安全性依赖于由私钥计算公钥非常简单,反过来由公钥计算私钥非常困难。

常见的非对称加密有RSA 和 ECC(椭圆曲线)。关于RSA的介绍,可以去B站观看李永乐老师的讲解。关于 ECC 的 介绍可以参考我的另一篇文章  小白也能看懂的 ECC 椭圆曲线加密算法

简单来说 RSA算法的安全性依赖于将两个大素数相乘得到的大整数进行分解的难度。在当前计算能力下,对于足够大的RSA密钥,没有有效的算法可以在合理的时间内解决大数分解问题。 ECC算法的安全性依赖于在椭圆曲线上找到一个点的离散对数的难度。与RSA相比,ECC可以实现相同的安全性级别但使用更短的密钥长度,  ECC 256位密钥的安全性大致相当于RSA 3072位密钥的安全性(在2023年的算力条件下,2048位的 RSA 密钥可以被看作是非常安全的密钥长度).

哈希函数(信息摘要函数)

哈希函数也称 信息摘要函数,)是一种将任意长度的输入数据映射到固定长度的输出数据的算法。哈希函数通过对输入数据进行计算,生成一个称为哈希值(Hash Value)或摘要(Digest)的固定长度的输出。

哈希函数具有以下特点:

  1. 固定长度输出:无论输入数据的大小,哈希函数都会生成具有固定长度的哈希值。
  2. 确定性:对于相同的输入,哈希函数总是生成相同的哈希值。
  3. 高效性:计算哈希值的过程应该是高效的,即使输入数据非常大,计算时间也应该是可接受的。
  4. 抗碰撞性(雪崩性):输入数据发生微小的改变,哈希值应该会发生显著的变化。这种性质保证了对输入数据的任何细微改变都会导致完全不同的哈希值
  5. 单向性: 给定输入的数据,可以快速地,确定性地计算出输出数据; 但反之, 给定一个哈希值, 无法反推输入的数据是什么。这点也很好理解,因为哈希函数的输出长度是固定的,这些固定值必然是可以被枚举出来的,而输入却是无穷无尽种的,输入和输出的对应关系是多对1的关系。

常见的 哈希函数有 MD5, sha1, sha256,Blake2 等。其中 sha1 和 MD5都已被证明不再安全,其主要被攻破的地方在于防碰撞性。 具体的论文可以参考 Finding Collisions in the Full SHA-1

在现代的密码体系中,几乎没有公司选择保存用户明文密码,或者是加密后的明文密码,而是选择保存 密码的 哈希值,这样即使是数据库泄露,攻击者也只能拿到 哈希值, 无法反推出用户的密码。只要拿不到密码明文,攻击者就无法登录应用,还可以采取给用户重置密码等止损操作。

彩虹表攻击

上面说过哈希函数具有不可逆性, 所以根据哈希值反推出输入是不可能的。但是如果攻击者有一张表,表的一列是用户常见密码(比如123456789, administrator这种),另一列是对应的密码哈希值,那么攻击者就可以遍历这张表,看看哪一个哈希值等于用户在登陆中使用的哈希值(或者已经获取到了数据库的哈希值),进而得出用户的明文密码是什么。这张表就称作彩虹表。

为了防止彩虹表攻击,我们可以采取以下措施:

1.  加盐(Salt):在哈希之前,给用户的密码后面加上一个随机字符串,这个随机字符串就是盐(称作盐值)。每个用户的盐值都是唯一的,即使密码相同,哈希值也会有所不同。这增加了破解的难度,因为攻击者需要为每个可能的盐值生成彩虹表。

2.  迭代次数:多次应用哈希函数。通过多次迭代哈希函数,可以增加破解时的计算量,使攻击者需要更多的时间和资源来破解密码。

3.  强密码策略:因为彩虹表里面可不能枚举出所有的密码组合,只能是一些常用密码,所以密码越复杂,理论上越不容易被纳入彩虹表中。我们可以设置一些额外的密码规则机制,比如对密码长度做出要求,对数字字母符合组合做出要求等。

重放攻击

重放攻击指的是 攻击者通过重复发送或再次播放之前截获的通信数据包来实施攻击。 比如攻击者在网路传输中监听到了某个用户登录时的请求,攻击者虽然无法破解密文,但攻击者可以悄悄地把这个请求记录下来,然后过一阵子把这个请求再放出来,以达到冒充用户登录的行为。

防范重放攻击一般有两种方式:

  1. 增加时间戳校验,在加密的时候在原文后面附上一个时间戳,服务端解密后查看这个时间戳,如果解密后的时间戳 与服务端当前的时间戳相差过久(在现代手机或电脑中一般都有自定时间同步,一般对客户端和服务端的时间差控制在分钟级,也就是10分钟以内都可以),就说明这个请求是很早以前过来的,可能被重放攻击了,直接返回用户名或密码错误。时间戳校验只能保障一定时间范围外攻击不生效。比如服务端和客户端时间戳不同步,相差10秒,那么我设置冗余时间为30秒,超过服务端时间戳30秒以外的请求不予通过,但是攻击者可以在30秒以内发起重放攻击。这时候就需要nonce 校验了
  2. 增加nonce校验。 所谓 nonce 校验 就是在 密码校验成功后对请求进行标记(比如把密文进行哈希存在缓存中),如果在短时间内密码校验请求命中了这个缓存,则说明这个请求是被重放过的,服务端立刻返回用户名或密码错误。

中间人攻击:

中间人攻击指的是, 攻击者在客户端和服务端中间对信道进行拦截,篡改,冒充等攻击。最常见的就比如下面这幅图:

middle_attack.drawio.png

在上图中客户端尝试从服务端获取公钥,攻击者在中间把这个请求拦截下来了,并且冒充服务端返回了一把假的公钥(攻击者自己的),客户端拿到假的公钥进行加密,将密文尝试发送到服务端,攻击者又把这个请求拦截下来,用自己的私钥进行解密,这样攻击者就可以套取出来原文信息。

要解决此种攻击方式,最根本的解决方案在于公钥一定是可信任的,可以使用CA机构使用数字证书的方式颁发这把公钥,关于什么是数字证书可以移步到我的另一篇博客 使用 crypto/x509 实现 证书链的 生成 与 校验。还有一种更简单的方法,就是客户端可以把这把公钥写在客户端配置里面或者把公钥打包打进客户端里面(只有服务端返回的公钥和客户端配置文件的公钥一致才进行加密)。这样攻击者就无法对合法的公钥进行篡改,从而套取原文信息。

暴力破解攻击

暴力破解攻击是最原始,最耗时的攻击手段。通常是指在密码协议本身没有明显漏洞的情况下,攻击者通过暴力枚举密钥,暴力枚举用户密码而进行的攻击手段。一般不会有攻击者选择暴力破解,除非攻击者已知服务端密钥/用户密码长度特别短,或者服务端密钥/用户密码的熵值特别低的情况下才会采用暴力破解。

其他技术和攻击

上面是一些常用的加密方式和攻击手段,还有其他工程实现上的加密方式以及不易察觉的攻击方式,我会放到后面密码协议的实现上面说。

高效且安全的密码体系

协议详解

下面的流程图是我设计的一套密码体系,这套密码体系可以在http协议下安全传输(无需https),其安全强度甚至超过了某些信息安全领域的厂商。这套密码体系重点关注点在于关注密码层面,包括传输密码,校验密码,密码强度等方面,对于其他方向比如用户完成认证以后如何颁发凭证(如cookie,token)等后续操作不做过多关注。针对前面说的各种攻击手段,都有相应的解决方案。

pwd_protocol.drawio.png 下面详细解释这套密码体系 :

0. 数据库表准备

表有4个字段,username代表用户名, salt1 和 salt2 均为盐值,其中 salt1 明文方式返回给客户端, salt2 只在服务端可见,salt2 不返回给客户端, salt2HashInfo 为最终经过多次哈希过的字符串,也是存储的最终校验的密码。

下面开始用户登陆步骤详解

1.获取用户名,密码明文

客户端获取到用户输入的用户名username和明文密码pwd

2. 客户端发送用户名服务端

客户端明文发送用户名给服务端

3. 服务端查询盐值1

服务端根据用户名查询盐值 salt1。 随后返回 盐值 salt1 和 服务端公钥 pubKey。 这里要注意如果输入的用户不存在怎么办。用户不存在的话数据库是差不多对应的 salt1 的。这里切忌使用一个随机的字符串当作 salt1 返回。如果为用户存在返回的盐值是固定的,用户不存在每次返回的盐值是随机的,那么攻击者就可以利用这个漏洞探测出哪些用户存在哪些用户不存在,这对于to C的产品影响不大因为大家都是用昵称;但对于to B的产品就是严重的漏洞,to B 一般使用员工的真名拼音用作用户名。我的解决方案是将这个不存在的用户加一个盐值进行哈希一次,再返回给客户端,这样对于不存在用户来说,也无法通过返回的盐值判断是否真实存在。

4. 服务端返回盐值1和公钥

服务端以明文的方式返回salt1 和 pubKey 给客户端。 前面说过,加盐值是为了防止彩虹表攻击,哪怕加了一丢丢盐进去,彩虹表就会整个报废,更何况每个用户的盐值都是随机的,所以这里可以大胆地返回随机的盐值。

5. 客户端制作数字信封

  1. 客户端校验 pubKey 合法性
  2. 随后计算 $sal1HashInfo = hash(pwd||salt1)^N$ 这里表示将 用户密码pwd和盐值1 salt1 进行级联(可以使用\n进行拼接) 然后再 哈希N次。 这里的N至少应该为1000,你可能会问为什么需要哈希这么多次,这是为了抵御暴力攻击,哈希N次对于正常用户登录的时候发生在客户端,计算的代价可以忽略不记。但对于攻击者来说这就不好办了,攻击者要是想要暴力破解saltHashInfo 也需要 哈希N次,这就非常消耗资源了。后面我会给个例子计算攻击者的暴力破解的计算代价。
  3. 随机化会话密钥 seesionKey, 所谓会话密钥就是每一次用户登陆都会随机化一把密钥,用完就丢,一次性有效。
  4. 计算  $loginInfo=AesEnc(salt1Info||username||timeStamp), using$ $ sessionKey$, 也就是使用会话密钥对 salt1Info, 用户名,时间戳级联以后进行AES加密。
  5. 计算 $keyInfo = EccEnc(sessionKey), using$  $pubKey$, 也就是使用服务端公钥对会话密钥sessionKey 进行加密

上面这种加密的方式称作数字信封,loginInfo 就是信封, keyInfo 就是打开信封的钥匙。信封是使用会话密钥加密过的,会话密钥又被服务端公钥加密过。

6. 客户端发送数字信封

客户端发送 loginInfo 和 keyInfo 给服务端,请求校验密码

7. 服务端校验密码

  1. 解密会话密钥 $sessionKey = EccDec(keyInfo)$, $using  priKey, 使用服务端私钥对 keyInfo 进行ECC 解密得到会话密钥 sessionKey
  2. 解密数字信封, $salt1HashInfo, username, timeStamp = AesDec(loginInfo), using$  $sessionKey$
  3. 校验 用户名和会话密钥是否在缓存中, 如果位于缓存中则说明此处请求被重放
  4. 校验时间戳, 若时间戳与服务端当前时间戳相差过远则返回账号或密码错误
  5. 校验用户名是否存在, 若用户存在则返回账号或密码错误
  6. 根据用户名查找盐值 salt2
  7. 计算 $calSalt2HashInfo = hash(salt1HashInfo||username||salt2)$
  8. 校验计算出来的 calSalt2HashInfo 与数据库里面的  salt2HashInfo 进行比较,若一致则校验成功
  9. 将 用户名和会话密钥级联后放入缓存

8.服务端返回校验结果

这步没什么好说的,服务端返回校验成功还是校验失败

9. 客户端告知用户登录成功还是账户名或密码错误

这步也没什么好说的,客户端告知用户密码校验结果

性能分析

  1. 整个密码交互流程只需要3次请求/返回,已经是理论极限, 不可能减少到两次,除非客户端提前知道用户盐值和服务端公钥。
  2. 整个流程最大的瓶颈在于计算 salt1HashInfo, 但这是在客户端完成的,不对服务端造成性能造成影响
  3. 服务端的性能开销主要在于数据库查询和密码学操作。数据库查询可以使用缓存预热进行优化,在获取salt1时将salt2, salt2HashInfo也准备好,从而避免重复查询操作(这里我就不实现了,只关注密码协议相关的实现)。 密码学操作主要是一次ECC解密 和 AES解密。AES 是对称解密性能开销较小, 256 位 ECC 解密 相比 2048 位 RSA 解密 在安全性更高的情况下, 性能开销也更小。

安全性分析

一般情况下的安全性分析

  1. 彩虹表攻击, 由于在 哈希前加入了盐值, 且每个用户的盐值并不相同, 所以彩虹表攻击无效

  2. 中间人攻击, 由于客户端可以对服务端公钥进行验证, 所以中间人无法通过篡改公钥进而套取用户名信息进行攻击。中间人因为不掌握服务端私钥,所以即使拦截下来了 loginInfo ,也无法进行解密

  3. 重放攻击, 重放攻击由两个方面进行防范: 一是使用时间戳校验, 只有在一定时间范围内(允许因为时间不同步导致的时间差,称为容错时间)的请求可以被接受,超过容错时间直接返回用户名或密码错误。 二是使用nonce校验。时间戳校验对容错时间范围外的请求可以返回错误,但对于容错时间内的请求无法识别是首次请求,还是到重放请求。所以我们需要对已经成功的请求做出标记,并将这个标记送入缓存内,缓存的生效时间应该是两倍的容错时间。比如容错时间是30秒,客户端请求时的时间戳是 8:00:30, 服务端校验的时候时间戳是 8:00:00, 此时校验通过。 那么攻击者在8:00:30重放一次上一次成功的请求(时间戳为8:00:00),到达服务端时服务端时间戳为 8:00:30, 此时距离上一次成功的请求过去刚好两倍的 容错时间,依旧校验通过。为了预防这种情况,在每一次请求校验密码成功后,服务端都应该记录这次记录,记录的有效期至少应为两倍的容错时间。我的方案是将 用户名和会话密钥级联当作标记 送入缓存,设置两倍容错时间的有效期。

  4. 用户探测攻击, 即使用户不存在, 依然返回一个固定的随机字符串, 所以攻击者无法通过盐值探测某个用户是否存在。 在查询到用户后,我们可以随机休眠了0-100纳秒, 所以攻击者无法通过接口返回的时间来判断用户是否存在。随机休眠的优化一般只有在用户规模较大,查询数据库耗时相差较大的情况下才会有效果,否则数据库查询的时间和网络本身波动的时间交杂在一起了,根本看不出区别。

  5. 暴力攻击:256 位ECC 密钥的强度约等于 3072位RSA密钥的强度。 在密钥不泄露,且按照一年一换密钥的情况下,攻击者几乎不可能暴力破解出数字信封内被加密的密文

极端情况下的安全性分析:

极端情况指的是 攻击者完全掌握密码协议的每个步骤, 攻击者已经取得了ECC的私钥 , 攻击者已经黑入了数据库, 获取到了用户的所有信息, 包括用户名,盐值 salt1,salt2, salt2Hash2Info, 在此种情况下企图破解 用户密码明文 password.

你可能会问,攻击者已经攻入数据库里面了,破解用户密码还有意义吗? 其实是有意义的,攻入了数据库只能代表攻击者能对数据进行新增,篡改,删除,但如果破解了用户密码的明文, 那么攻击者则可以冒充用户进行登录,伪装成一个正常用户(尤其是系统管理员)进行恶意操作。 很显然这种伪装成正常用户的行为更加隐秘,危害更大

下面我简单给个例子推导一下攻击者在极端情况下若想要破解密码原文,需要的代价是多大。

校验密码最终需要和数据库中的 salt2HashInfo 进行比对, 前提需要计算 salt1HashInfo。salt1HashInfo 需要计算多轮哈希, 而 salt2HashInfo 只需要计算一轮,所以主要的计算瓶颈在于 计算 salt1HashInfo. 这也是为什么需要 哈希1000轮以上的原因, 因为这是阻挡攻击者暴力破解的最后一道屏障

假设已知用户密码是8位大小写字母数字的排列组合(先忽略特殊字符, 只考虑数字字母), 那么密码组合一共有 (26+26+10)^8 个组合, 也就是 218,340,105,584,896 种组合。实际情况下攻击者并不一定知道密码的位数,这里以对攻击者较为乐观的情况, 也就是用户仅有8位数字或者字母为密码的情况估计。

我的电脑(普通办公笔记本)计算一次 saltHash1Info (1024轮 sha256)耗时200ms。 假设攻击者的计算资源是我的10000倍。

那么攻击者想要暴力破解 saltHash1需要的时间是 218,340,105,584,896 * (200 /10000)ms =4,366,802,111,697.92 ms, 换算成秒的话就是 4,366,802,111.69792秒, 换算成天的话就是 50,541,69 天,约 138 年。

如果配合上定期修改密码(半年或一年), 配合上密码规则(设置最小密码长度,必须大小写字母数字组合),那么即使在极端条件下,暴力破解密码原文也几乎不可能。

代码实现

下面我将使用 golang 对上面的密码协议进行实现,代码略多,但步骤非常详细:

新建工程

创建 pwd_demo 文件夹,命令行输入


go mod init pwd_demo

go mod tidy

随后创建各目录与文件如下:

pwd_demo
 
    -- key 生成、保存 ECC 公私钥对文件夹

        -- main.go 生成 ECC 公私钥对的主函数

        -- private_key.pem 生成的 ECC 私钥文件

        -- public_key.pem 生成的 ECC 公钥文件

   

    -- client 客户端文件夹

        -- main.go 客户端处理用户输入的用户名和密码,请求服务端校验密码
   

    -- common 一些客户端,服务端都需要调用 公共代码

        -- common.go
   

    -- server 服务端文件夹

        -- main.go 服务端处理获取公钥,获取盐值,校验密码的函数


    go.mod
        go.sum

key/main.go

key 目录下 main.go  用于生成ECC公私对并保存到 key目录下的 private_key.pem 和 public_key.pem 中。代码很简单,大家看注释应该就懂了。

package main

import (
   "crypto/ecdsa"
   "crypto/elliptic"
   "crypto/rand"
   "crypto/x509"
   "encoding/pem"
   "fmt"
   "os"
)
 

func main() {

   // generate key pair(256 bits private key)
   privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
   if err != nil {
      fmt.Println("Failed to generate private key:", err)
      return
   }

   // save the private key in string format into local file
   privateKeyFile, err := os.Create("./key/private_key.pem")
   if err != nil {
      fmt.Println("Failed to create private key file:", err)
      return
   }

   defer privateKeyFile.Close()
   privateKeyBytes, _ := x509.MarshalECPrivateKey(privateKey)

   err = pem.Encode(privateKeyFile, &pem.Block{
      Type:  "EC PRIVATE KEY",
      Bytes: privateKeyBytes,
   })
   if err != nil {
      fmt.Println("Failed to write private key to file:", err)
      return
   }
 
   // get public key from private key
   publicKey := privateKey.PublicKey

   // save the public key in string format into local file
   publicKeyFile, err := os.Create("./key/public_key.pem")
   if err != nil {
      fmt.Println("Failed to create public key file:", err)
      return
   }
   defer publicKeyFile.Close()

   derBytes, err := x509.MarshalPKIXPublicKey(&publicKey)
   if err != nil {
      fmt.Println("Failed to marshal public key:", err)
      return
   }

   err = pem.Encode(publicKeyFile, &pem.Block{
      Type:  "PUBLIC KEY",
      Bytes: derBytes,
   })

   if err != nil {
      fmt.Println("Failed to write public key to file:", err)
      return
   }
 
   fmt.Println("Keys generated and saved to files.")
}

在 key 目录下运行go run main.go, 预期会生成两个文件: private_key.pem 和 public_key.pem。

比如我的private_key.pem :

-----BEGIN EC PRIVATE KEY-----
MHcCAQEEILgdGKSnnmbJqKeRvFIBkKSijI6A9PN0zQcnDTJWbENHoAoGCCqGSM49
AwEHoUQDQgAEEeO1ycZJC32ABXt9QZ8BbwIV+71Ai3OffrFslCotLEicb0zmWOe+
41snB5/pDJiX/m3VnA6C6B4yaXR0+iRMdg==
-----END EC PRIVATE KEY-----

我的 public_key.pem

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEeO1ycZJC32ABXt9QZ8BbwIV+71A
i3OffrFslCotLEicb0zmWOe+41snB5/pDJiX/m3VnA6C6B4yaXR0+iRMdg==
-----END PUBLIC KEY-----

common/ommon.go

common.go 主要定义了客户端和服务端一些公共的类型和函数,包括:

  1. GetPwdInfoReq, 客户端获取盐值 salt1 和 公钥pubKey 的请求体
  2. GetPwdInfoResp, 客户端获取盐值 salt1 和 公钥pubKey 对应 服务端的返回体
  3. EccLoginReq, 客户端使用ecc加密后请求校验密码的请求体
  4. EccLoginResp, 客户端使用ecc加密后请求校验密码 对应服务端的 返回体
  5. Getsalt1HashInfo, 用于计算 $sal1HashInfo = hash(pwd||salt1)^N$, 这里的N我取1024,也就是使用sha256 进行1024轮哈希计算
  6. PKCS7Padding, PKCS7UnPadding,   padding 和 unpadding 操作, 配合 AES CBC加解密操作
  7. AesEncryptCBC, AesDecryptCBC,AES CBC 模式 加解密
package common

import (
   "bytes"
   "crypto/aes"
   "crypto/cipher"
   "crypto/sha256"
   "fmt"
)

type GetPwdInfoReq struct {
   Username string `json:"username"`
}

type GetPwdInfoResp struct {
   ErrMsg string `json:"errMsg"`
   PubKey string `json:"pubKey"`
   Salt1  string `json:"salt1"`
}

type EccLoginReq struct {
   LoginInfo string `json:"loginInfo"`
   KeyInfo   string `json:"KeyInfo"`
}

type EccLoginResp struct {
   ErrMsg string `json:"errMsg"`
}

func GetSalt1HashInfo(pwd, salt1 string) string {
   message := pwd + "\n" + salt1
   result := sha256.Sum256([]byte(message))
   for i := 0; i < 1023; i++ {
      result = sha256.Sum256(result[:])
   }
   return fmt.Sprintf("%x", result)
}

func PKCS7Padding(unPaddedText []byte, blockSize int) []byte {
   padCount := blockSize - len(unPaddedText)%blockSize
   padText := bytes.Repeat([]byte{byte(padCount)}, padCount)
   return append(unPaddedText, padText...)
}

func AesEncryptCBC(plainText []byte, key []byte) []byte {
   block, _ := aes.NewCipher(key)                 // key block
   blockSize := block.BlockSize()                 // get block size of key
   plainText = PKCS7Padding(plainText, blockSize) // pkcs7 padding
   iv := key[:blockSize]                          // initial vector
   blockMode := cipher.NewCBCEncrypter(block, iv) // cbc encrypt
   cipherText := make([]byte, len(plainText))     // make space for cipher text
   blockMode.CryptBlocks(cipherText, plainText)   // encrypt
   return cipherText
}

client/main.go

main.go 和上面协议详解中的过程完全相同, main 函数 首先去文件里面读合法的公钥,这把公钥预期和服务端返回的是一样的, 随后定义了用户名密码, 然后请求服务端获取salt1 和 pubKey, 校验 pubKey, 计算 salt1HashInfo, 制作数字信封,请求服务端校验密码,最后打印校验结果。特别的是在传输过程中,我对 loginInfo 和 keyInfo 做了 base64 编码 以方便在 http 协议中传输

package main

import (
   "bytes"
   "crypto/ecdsa"
   "crypto/rand"
   "crypto/x509"
   "encoding/base64"
   "encoding/json"
   "encoding/pem"
   "errors"
   "fmt"
   "github.com/ethereum/go-ethereum/crypto/ecies"
   "io"
   "net/http"
   "os"
   "pwd_demo/common"
   "strconv"
   "time"
)

func readValidPubKey() (*ecies.PublicKey, string, error) {

   // read pub key file
   pemData, err := os.ReadFile("../key/public_key.pem")
   if err != nil {
      fmt.Println("read pub key err :", err)
      return nil, "", err
   }

   // pem.decode pub key file
   block, _ := pem.Decode(pemData)
   if block == nil {
      fmt.Println("pem.Decode(pemData) get nil block")
      return nil, "", err
   }

   // parse pub key
   pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
   if err != nil {
      fmt.Println("x509.ParsePKIXPublicKey(block.Bytes) err :", err)
      return nil, "", err
   }

   // convert pub key into *ecdsa.PublicKey
   ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
   if !ok {
      fmt.Println("pubKey.(*ecdsa.PublicKey) error")
      return nil, "", errors.New("publicKey.(*ecdsa.PublicKey)")
   }

   // convert  *ecdsa.PublicKey into *ecies.PublicKey
   eciesPubKey := ecies.ImportECDSAPublic(ecdsaPubKey)

   return eciesPubKey, string(pemData), nil

}

// generate random key
func generateRandomKey(length int) []byte {
   key := make([]byte, length)
   if _, err := io.ReadFull(rand.Reader, key); err != nil {
      fmt.Println("Failed to generate random key:", err)
   }
   return key
}

func main() {

   // read valid pub key
   eccPubKey, eccPubKeyStr, err := readValidPubKey()
   if err != nil {
      fmt.Println("HTTP request failed:", err)
      return
   }

   var usernmae = "Tom"
   var pwd = "Tom@2024"

   // get salt1 and pub key from server
   getPwdInfoReq := common.GetPwdInfoReq{Username: usernmae}

   getPwdInfoReqBytes, err := json.Marshal(getPwdInfoReq)
   if err != nil {
      fmt.Println("json.Marshal(getPwdInfoReq) err:", err)
      return
   }

   // make request to get salt1 and public key
   resp, err := http.Post("http://localhost:2000/getPwdInfo", "application/json", bytes.NewBuffer(getPwdInfoReqBytes))
   if err != nil {
      fmt.Println("HTTP request failed:", err)
      return
   }
   defer resp.Body.Close()

   getPwdInfoRespBytes, err := io.ReadAll(resp.Body)
   if err != nil {
      fmt.Println("Failed to read response body:", err)
      return
   }

   var getPwdInfoResp common.GetPwdInfoResp
   err = json.Unmarshal(getPwdInfoRespBytes, &getPwdInfoResp)
   if err != nil {
      fmt.Println(" json.Unmarshal(getPwdInfoRespBytes,&getPwdInfoResp err:", err)
      return
   }

   // verify public key
   if getPwdInfoResp.PubKey != eccPubKeyStr {
      fmt.Println("invalid pub key found")
      fmt.Println("getPwdInfoResp.PubKey = ", getPwdInfoResp.PubKey)
      fmt.Println("eccPubKeyStr = ", eccPubKeyStr)
      return
   }

   // calculate  salt1HashInfo
   salt1HashInfo := common.GetSalt1HashInfo(pwd, getPwdInfoResp.Salt1)

   // make digital envelope
   sessionKey := generateRandomKey(32)

   timeStampStr := strconv.FormatInt(time.Now().Unix(), 10)

   loginInfo := common.AesEncryptCBC([]byte(salt1HashInfo+"\n"+usernmae+"\n"+timeStampStr), sessionKey)
   loginInfoBase64 := base64.StdEncoding.EncodeToString(loginInfo)

   keyInfoBytes, err := ecies.Encrypt(rand.Reader, eccPubKey, sessionKey, nil, nil)
   if err != nil {
      fmt.Println("ecies.Encrypt err:", err)
      return
   }
   keyInfoBase64 := base64.StdEncoding.EncodeToString(keyInfoBytes)

   // make ecc login request to verify username and password
   eccLoginReq := common.EccLoginReq{
      LoginInfo: loginInfoBase64,
      KeyInfo:   keyInfoBase64,
   }

   eccLoginReqBytes, err := json.Marshal(eccLoginReq)
   if err != nil {
      fmt.Println("json.Marshal(eccLoginReq) err:", err)
      return
   }

   resp, err = http.Post("http://localhost:2000/eccLogin", "application/json", bytes.NewBuffer(eccLoginReqBytes))
   if err != nil {
      fmt.Println("HTTP request failed:", err)
      return
   }
   defer resp.Body.Close()

   eccLoginRespBytes, err := io.ReadAll(resp.Body)
   if err != nil {
      fmt.Println("Failed to read response body:", err)
      return
   }

   fmt.Println(string(eccLoginRespBytes))

}

server/main.go

main.go 主要功能是 提供 获取盐值,公钥的接口 以及 校验密码的接口,以及一些辅助变量和函数:

  1. eccPriKey, 服务端私钥, *ecies.privateKey 类型
  2. eccPubKeyStr, 由私钥钥计算得到的公钥, string 类型, 用于返回给客户端
  3. userSessionKeyCache, 校验密码成功后,记录用户名和会话密钥的缓存,防重放攻击。这里简单使用了本地缓存, 如果是多台机器,一个使用分布式缓存。
  4. UserPwdInfo, 存储用户名 username, 盐值salt1,salt2, salt2HashInfo, 相当于数据库表的一行
  5. userPwdInfoMap, username 到 UserPwdInfoMap 映射, 相当于数据库表,这里用一个 map 做简化
  6. initUserPwdInfoMap, 初始化 userPwdInfoMap, 也就是插入一条用户记录
  7. getUserSaltNotExist, 当用户不存在的时候, 计算 salt1, 我的实现是 $salt1 = sha256(username+sha256(username))$
  8. getSalt2HashInfo, 计算 salt2HashInfo

main 函数里面注册了两个http handler : getPwdInfoHandler 和 eccLoginHandler

getPwdInfo handler 判断用户是否存在, 从而返回数据库的 salt1 或者计算一个 salt1, 附上服务端公钥。

eccLogin handler 负责校验密码, 具体来说先使用服务端私钥解密会话密钥,再使用会话密钥解密 loginInfo 获得 salt1HashInfo, username, timestamp, 接着缓存中查找用户名级联会话密钥,若缓存中查到了说明此处请求是被重放的请求立刻返回错误,再校验时间戳,用户是否存在,根据loginInfo中的 salt1HashInfo, username 和 数据库的 salt2 计算 calSalt2HashInfo , 对比calSalt2HashInfo 和数据库的 salt2HashInfo 是否一致, 若一致说明密码校验通过。校验通过后将 用户名级联会话密钥放入缓存中,有效期至少为两倍的容错时间。

值得注意的是, 在密码校验这种敏感场景, 不应对外暴露太多的错误信息, 凡是和密码协议相关的error, 不管是时间戳校验出错, 还是 base64 解码出错,还是会话密钥解密错误, 还是用户名不存在, 统统应该返回 用户名或密码错误, 以免被攻击者探测出有价值信息。

package main

import (
   "crypto/aes"
   "crypto/cipher"
   "crypto/rand"
   "crypto/sha256"
   "crypto/x509"
   "encoding/base64"
   "encoding/hex"
   "encoding/json"
   "encoding/pem"
   "errors"
   "fmt"
   "github.com/ethereum/go-ethereum/crypto/ecies"
   "github.com/patrickmn/go-cache"
   "io"
   "math/big"
   "net/http"
   "os"
   "pwd_demo/common"
   "strconv"
   "strings"
   "time"
)

var eccPriKey *ecies.PrivateKey
var eccPubKeyStr string

var userSessionKeyCache *cache.Cache // prevent replay attacks

// define user pwd info, include username, salt1, salt
type UserPwdInfo struct {
   Username      string
   Salt1         string
   Salt2         string
   Salt2HashInfo string
}

// map[username]UserPwdInfo
var userPwdInfoMap map[string]UserPwdInfo

// init userPwdInfoMap
func initUserPwdInfoMap(username string, pwd string, salt1 string, salt2 string) {

   salt1HashInfo := common.GetSalt1HashInfo(pwd, salt1)
   salt2HashInfo := getSalt2HashInfo(salt1HashInfo, username, salt2)
   userPwdInfoMap = make(map[string]UserPwdInfo)

   userPwdInfoMap[username] = UserPwdInfo{
      Username:      username,
      Salt1:         salt1,
      Salt2:         salt2,
      Salt2HashInfo: salt2HashInfo,
   }

   fmt.Printf("user %s pwd info = %v \n", username, userPwdInfoMap[username])

}

// calculate salt1 if user not exist
func getUserSaltNotExist(user string) string {
   userSalt1Salt := sha256.Sum256([]byte(user))
   userSalt1Salt = sha256.Sum256(userSalt1Salt[:])
   userSalt1 := sha256.Sum256([]byte(user + fmt.Sprintf("%x", userSalt1Salt)))
   userSalt1 = sha256.Sum256(userSalt1[:])
   return fmt.Sprintf("%x", userSalt1[0:8])
}

func readKeyPair() error {
   // read private key
   privateKeyFile, err := os.ReadFile("../key/private_key.pem")
   if err != nil {
      fmt.Println("Failed to read private key file:", err)
      return err
   }

   // decode private key
   privateKeyBlock, _ := pem.Decode(privateKeyFile)
   if privateKeyBlock == nil || privateKeyBlock.Type != "EC PRIVATE KEY" {
      fmt.Println("Failed to decode private key PEM block")
      return err
   }

   // parse private key
   privateKey, err := x509.ParseECPrivateKey(privateKeyBlock.Bytes)
   if err != nil {
      fmt.Println("Failed to parse private key:", err)
      return err
   }

   // private key into *ecies.PrivateKey format
   eccPriKey = ecies.ImportECDSA(privateKey)

   // parse publick key into string format
   derBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
   if err != nil {
      fmt.Println("Failed to marshal public key:", err)
      return err
   }

   pemBlock := &pem.Block{
      Type:  "PUBLIC KEY",
      Bytes: derBytes,
   }

   eccPubKeyStr = string(pem.EncodeToMemory(pemBlock))

   return nil
}

func initUserSessionKeyCache() error {
   userSessionKeyCache = cache.New(1*time.Minute, 1*time.Minute)
   if userSessionKeyCache == nil {
      println("userSessionKeyCache == nil")
      return errors.New("userSessionKeyCache == nil")
   }
   return nil
}

func getSalt2HashInfo(salt1HashInfo, username, salt2 string) string {
   message := salt1HashInfo + "\n" + username + "\n" + salt2
   result := sha256.Sum256([]byte(message))
   return hex.EncodeToString(result[:])
}

func main() {

   err := readKeyPair()
   if err != nil {
      panic(err)
      return
   }

   err = initUserSessionKeyCache()
   if err != nil {
      panic(err)
      return
   }

   username := "Tom"
   salt1 := "18eb9nuv69lu18zg"
   salt2 := "gh01B8cmv742x3b6"
   pwd := "Tom@2024"
   initUserPwdInfoMap(username, pwd, salt1, salt2)

   http.HandleFunc("/getPwdInfo", getPwdInfoHandler)
   http.HandleFunc("/eccLogin", eccLoginHandler)
   go func() {
      http.ListenAndServe(":2000", nil)
   }()

   select {}

}

func getPwdInfoHandler(w http.ResponseWriter, r *http.Request) {

   var resp common.GetPwdInfoResp

   // check method
   if r.Method != http.MethodPost {
      fmt.Println("error method = ", r.Method)
      resp.ErrMsg = "req parameter error"
      MakeResponse(w, resp)
      return
   }

   // parse body
   bodyBytes, err := io.ReadAll(r.Body)
   if err != nil {
      fmt.Println("Fail to read request body")
      resp.ErrMsg = "req parameter error"
      MakeResponse(w, resp)
      return
   }

   var req common.GetPwdInfoReq
   err = json.Unmarshal(bodyBytes, &req)
   if err != nil {
      fmt.Println("Fail to convert request body into getPwdInfoReq")
      resp.ErrMsg = "req parameter error"
      MakeResponse(w, resp)
      return
   }

   // prepare pub key
   resp.PubKey = eccPubKeyStr

   // prepare salt
   userPwdInfo, ok := userPwdInfoMap[req.Username]
   if ok {
      resp.Salt1 = userPwdInfo.Salt1
      resp.ErrMsg = "success"
      randomInt, _ := rand.Int(rand.Reader, big.NewInt(100))
      ns := randomInt.Int64()
      time.Sleep(time.Duration(ns) * time.Nanosecond)
      MakeResponse(w, resp)
      return
   } else {
      resp.Salt1 = getUserSaltNotExist(req.Username)
      resp.ErrMsg = "success"
      MakeResponse(w, resp)
      return
   }

}

func eccLoginHandler(w http.ResponseWriter, r *http.Request) {

   var resp common.EccLoginResp

   // check method
   if r.Method != http.MethodPost {
      fmt.Println("error method = ", r.Method)
      resp.ErrMsg = "req parameter error"
      MakeResponse(w, resp)
      return
   }

   // parse body
   bodyBytes, err := io.ReadAll(r.Body)
   if err != nil {
      fmt.Println("Fail to read request body")
      resp.ErrMsg = "req parameter error"
      MakeResponse(w, resp)
      return
   }

   var req common.EccLoginReq
   err = json.Unmarshal(bodyBytes, &req)
   if err != nil {
      fmt.Println("Fail to convert request body into EccLoginReq")
      resp.ErrMsg = "req parameter error"
      MakeResponse(w, resp)
      return
   }

   // decode session key
   keyInfoBase64 := req.KeyInfo
   keyInfoBytes, err := base64.StdEncoding.DecodeString(keyInfoBase64)
   if err != nil {
      fmt.Println("base64.StdEncoding.DecodeString(keyInfoBase64), err = ", err)
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   sessionkey, err := eccPriKey.Decrypt(keyInfoBytes, nil, nil)
   if err != nil {
      fmt.Println("Fail to convert request body into EccLoginReq")
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   // decode digital envelope
   loginInfoCipherBase64 := req.LoginInfo
   loginInfoCipherBytes, err := base64.StdEncoding.DecodeString(loginInfoCipherBase64)
   if err != nil {
      fmt.Println("base64.StdEncoding.DecodeString(loginInfoBase64), err = ", err)
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   loginInfoBytes := aesDecryptCBC(loginInfoCipherBytes, sessionkey)

   loginInfoList := strings.Split(string(loginInfoBytes), "\n")
   if len(loginInfoList) != 3 {
      fmt.Println("len(loginInfoList )!=3")
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   salt1HashInfo := loginInfoList[0]
   username := loginInfoList[1]
   timeStampStr := loginInfoList[2]

   // check username:sessionKey
   _, found := userSessionKeyCache.Get(username + ":" + string(sessionkey))
   if found {
      fmt.Printf("replicated user sessionKey %s%v found \n", username+":", sessionkey)
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   // check time stamp
   timeStamp, err := strconv.ParseInt(timeStampStr, 10, 64)
   if err != nil {
      fmt.Println("timestamp format error:", err)
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   timeNow := time.Now().Unix()
   if timeNow-timeStamp > 30 || timeStamp-timeNow > 30 {
      fmt.Printf("timestamp check error, time.Now().Unix() = %d, req time stamp = %d \n", timeNow, timeStamp)
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   // check user if exist or not
   userPwdInfo, ok := userPwdInfoMap[username]
   if !ok {
      fmt.Println(username + " not exist")
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   // check salt2HashInfo
   calSalt2HashInfo := getSalt2HashInfo(salt1HashInfo, username, userPwdInfo.Salt2)

   if calSalt2HashInfo != userPwdInfo.Salt2HashInfo {
      fmt.Println("calSalt2HashInfo!=userPwdInfo.Salt2HashInfo")
      resp.ErrMsg = "Username or password wrong"
      MakeResponse(w, resp)
      return
   }

   // set username:sessionKey in cache
   userSessionKeyCache.Set(username+":"+string(sessionkey), nil, cache.DefaultExpiration)

   resp.ErrMsg = "Login Sucess"
   MakeResponse(w, resp)
   return

}

func MakeResponse(w http.ResponseWriter, resp interface{}) {
   respBytes, _ := json.Marshal(resp)
   w.Write(respBytes)
}

func pKCS7UnPadding(paddedText []byte) []byte {
   length := len(paddedText)
   unpadding := int(paddedText[length-1])
   return paddedText[:(length - unpadding)]
}

func aesDecryptCBC(cipherText []byte, key []byte) []byte {
   block, _ := aes.NewCipher(key)                 // key block
   blockSize := block.BlockSize()                 // get block size of key
   iv := key[:blockSize]                          // initial vector
   blockMode := cipher.NewCBCDecrypter(block, iv) // cbc decrypt
   plainText := make([]byte, len(cipherText))     // make space for plain text
   blockMode.CryptBlocks(plainText, cipherText)   // decrypt
   plainText = pKCS7UnPadding(plainText)          // pkcs7 unpadding
   return plainText
}

运行起来

模拟校验密码通过

在 server 目录下运行 go run main.go 启动服务端

在 client 目录下运行 go run main.go 启动客户端, 命令行输出:


{"errMsg":"Login Sucess"}

代表密码校验通过

模拟校验密码不通过

修改 client/main.go 的用户名和密码, 再次运行 go run main.go , 命令行输出:


{"errMsg":"Username or password wrong"}

代表密码校验失败

如果是用户不存在, server 目录下运行 go run main.go 会输出

xxx not exist

如果是用户存在但是密码错误, server 目录下运行 go run main.go 会输出

calSalt2HashInfo!=userPwdInfo.Salt2HashInfo

模拟重放攻击

测试时间戳生效

在 client/main.go main 函数制作数字信封的时候加一行变成这样

timeStampStr := strconv.FormatInt(time.Now().Unix(), 10)
timeStampStr = "1704070800"

我给时间戳赋值了一个固定时间,这是2024年1月日1时0分0秒 UTC 时间的时间戳,随后启动客户端: 预期输出:

{"errMsg":"Username or password wrong"}

服务端预期输出:

timestamp check error, time.Now().Unix() = 1710251403, req time stamp = 1704070800 

这说明时间戳校验不过

测试 nonce 生效

将 client.go 下面的 generateRandomKey 函数修改一下 :

func generateRandomKey(length int) []byte {
   key := make([]byte, length)
   if _, err := io.ReadFull(rand.Reader, key); err != nil {
      fmt.Println("Failed to generate random key:", err)
   }

   key = []byte{103, 17, 142, 156, 245, 226, 24, 15, 85, 76, 49, 104, 135, 238, 29, 226, 22, 124, 109, 92, 28, 61, 134, 34, 73, 219, 214, 130, 172, 97, 195, 131}
   return key
}

我们多加了一行给 key 赋值一个固定的密钥,接着正常 在 client 目录下 允许go run main.go 启动 客户端 首次允许 client 的控制台预期输出:

{"errMsg":"Login Sucess"}

这代表正常登录。 随后立刻启动客户端, client 的控制台预期输出:

{"errMsg":"Username or password wrong"}

同时服务端控制台输出:

replicated user sessionKey Tom:[103 17 142 156 245 226 24 15 85 76 49 104 135 238 29 226 22 124 109 92 28 61 134 34 73 219 214 130 172 97 195 131] found 

这说明服务端发现了用户名和会话密钥是被重放的,立刻返回用户名或者密码错误。

总结

总结一下设计上和实现上的细节:

  1. 客户端一定要对公钥进行校验, 这是根除中间人攻击的方法。只要公钥是合法的,中间人在无法获得私钥的情况下,是没有办法破译到任何有效信息的,充其量只能是截获并重放, 而我们通过时间戳和nonce校验可以防止重放攻击

  2. 多重哈希,多重哈希是抵御护私钥泄露,数据库被攻破的极端情况下的最后一道屏障,请将哈希轮次设置在1000轮以上,此处消耗的是客户端资源, 不对服务端资源造成任何影响,任何情况下不应存储明文密码

  3. 运维人员一定要定期更换公私钥对,这个周期不需要特别频繁,以年为单位就行,在发年度大版本更换即可

  4. 普通用户设置密码时, 密码强度一定要有保障,避免长度过短, 只有数字或者只有字母,多个连续/重复的字母、数字

  5. 和校验密码相关的接口返回报错的时候尽可能的模糊,凡是和密码协议相关的,无论是私钥解密错误,还是传输的格式错误,还是用户不存在,统统返回 用户名或密码错误,避免给攻击者透露任何有价值的信息

  6. 注意用户探测这个漏洞, 在用户不存在时一定要返回一个固定的盐值,避免攻击者通过盐值探测出哪些用户是否存在;如果用户规模过大,查询数据库的耗时相差过远,那么在已查到用户的情况下可以随机的休眠几纳秒, 避免攻击者通过接口返回的时间探测出用户是否存在,这个优化只针对大用户规模,如果用户规模很小,则本身的网络波动时间就可以盖过数据库查询时间的波动。

  7. 用户的盐值一定要定期更换, 更换盐值的时机可以在用户修改密码/用户重置密码的时候进行,所以请提醒用户定期更换密码, 这个时机也不需要特别频繁,按照半年/年为单位即可。盐值的长度大于等于64位即可,不需要太长。

  8. 加密前请将时间戳放到原文里,一来方便服务端做时间戳校验, 二来对外可以增加随机性造成每次发送的数据都不同

  9. 在生成密钥时,无论是ECC/RSA公私钥对,还是会话密钥,请使用真随机数如golang的crypto包,而不是使用伪随机数比如 golang的 math包

看到这里,我已经从理论到实现,再到性能分析,最后是安全性分析,系统完整地讲述了一个用户密码体系。其中的各种实现细节,攻击的角度以及防范措施也有详细解释。相信看完这个博客,你也可以根据实际情况,设计出符合你家公司的密码体系。

巨人的肩膀

  1. https://www.bilibili.com/video/BV1Ts411H7u9/?spm_id_from=333.337.search-card.all.click&vd_source=d90b8fec5a4b9ae0f933b0dad2046720

  2. https://juejin.cn/post/7263886796756844604

  3. https://link.springer.com/chapter/10.1007/11535218_2

  4. https://github.com/ethereum/go-ethereum/tree/master/crypto/ecies