为了账号安全,请及时绑定邮箱和手机立即绑定

揭秘一次性密码:离线生成令牌背后的逻辑

嘿,晚上好!又是一个晚上,在回家的路上,我决定去查看一下邮递员放信的那个老式信箱。令我惊讶的是,我发现里面果然有东西!在打开它的时候,我心里默默祈祷了几分钟,希望那是一封来自霍格沃茨的迟到几十年的信。但很快我不得不回到现实,却发现这是一封来自银行的无聊“大人”信。我浏览了一下内容,发现我那个专为酷小孩设计的“全数字”银行已经被当地最大的市场参与者收购了。为了表示新的开始,他们在信封里附上了一张纸,写着:

使用说明也包括在内。

如果你和我一样,之前从未遇到过这样的技术革新,让我来分享一下我在信中了解到的内容:新所有者决定实施他们公司的安全政策,这意味着从今往后,所有用户账户都必须启用MFA(顺便说一句,这很棒)。你之前看到的那个设备会生成6位数的一次性验证码,这些验证码在登录银行账户时作为第二个验证步骤使用。基本上,它的工作方式和 Authy、Google Authenticator 或 2FAS 类似的应用程序一样,只不过以实体形式存在。

所以,我试了一下,登录过程很顺利:设备显示了一个6位数的验证码,我在银行应用中输入了这个验证码,然后就顺利登录了。耶!但是接着我突然想到一个问题:它不可能是以某种方式连接到互联网的,但它却能生成被我银行服务器接受的正确验证码。嗯……它里面会不会有个SIM卡或者其他类似的东西?这不太可能!

意识到我的生活从此会变得不一样,我开始思考之前提到的那些应用(Authy及其同类应用)?我的研究者本能被唤醒了,于是我将手机切换到飞行模式,然后令我惊讶的是,它们居然在离线状态下也能正常工作:它们还能生成服务器能接受的验证码。挺有意思的!

你可能不知道,但我总是把一次性验证码的过程视为理所当然,从未真正认真思考过(尤其是现在除非我在户外活动,否则我的手机几乎没有断网的时候),这让我感到惊讶的原因。但从安全角度看,这样做很有道理,因为生成过程完全是本地进行的,因此相对安全。那么,它是怎么工作的呢?

话说回来,像谷歌或ChatGPT这样的现代技术让找到答案变得很简单。但我觉得这个问题还挺有趣的,所以我决定先试一试。

需求

我们先从现有开始:

  • 一个离线码生成器,用于生成6位数的验证码
  • 一个服务器,接受并验证这些代码,并在代码正确时给出绿色通过信号

服务器验证的部分暗示服务器必须能够生成和离线设备一样的代码来比较。嗯……这可能挺有用的。

我继续玩这个新玩意儿,发现了不少新鲜事儿等等:

  • 如果我关掉然后再关一次,就能看到和之前一样的代码
  • 不过,偶尔它会变

我认为最有道理的解释是,这些代码可能存在一段时间。我想试着用“1-2-3-…-N”来数数它的持续时间,但实际情况并非如此:我从类似Authy这样的应用中得到了一个提示,发现了30秒的TTL。这个发现不错,我们把它加到已知的事实清单中。

我们现在来总结一下目前需要的东西:

  • 生成的六位数代码是可预测的,而不是随机的
  • 生成逻辑必须是可复现的,这意味着无论在哪种平台上,生成结果都是一样的
  • 代码的有效时间为30秒,这意味着在这30秒内生成的代码值是一样的
一个大问题

好的,但主要问题依旧没有答案:为什么离线应用能产生一个和另一个应用相同的数值?它们之间有什么共同点吗?

如果你是《魔戒》系列的粉丝,你可能还记得比尔博和咕噜玩谜语游戏,其中一个谜语是咕噜需要回答的。

这东西吞噬万物:
鸟兽、花草;
咬钢啮铁;
把坚硬的石头磨成粉末;
能杀国王,毁坏城市,
连高山都能夷为平地。

剧透警告,但巴金斯先生运气不错,无意间给出了正确答案——“时间!”信不信由你,这也是我们的谜底。只要应用程序内置时钟,任何两个或更多的应用程序都可以访问相同的时间。如今这已不是问题,设备也足够大,可以容纳时钟。环顾四周,你手腕上的手表、手机、电视、烤箱和墙上挂钟的时间可能都一样。看来我们找到了一次性密码(OTP)计算的基础。

挑战

依靠时间的流逝自有它的挑战:

  • 时区 — 该用哪个?
  • 时钟很容易不同步,这在分布式系统中是个大难题

咱们一个个来处理吧

  • 时区:这里最简单的解决方案是让所有设备都使用相同的时区,而UTC是一个不错的选择,因为它与地理位置无关。
  • 至于时钟不同步的情况:实际上,我们可能不需要解决这个问题,而是接受它不可避免。只要偏差在一到两秒之间,考虑到30秒的TTL,这是可以接受的。设备的制造商应该能够预测这样的偏差何时会出现,然后设备会将这个日期作为过期日期,银行会用新设备替换旧设备,或者找到一种办法让设备连接网络校准时钟。至少,这是我目前的想法。
实施:

好的,这已经确定了,接下来我们尝试以时间为基准来实现我们算法的第一个版本。由于我们对一个6位数的结果感兴趣,用时间戳代替人类可读的日期似乎是个聪明的做法。我们从这里开始吧。

    // 获取当前的时间戳:
    current := time.Now().Unix()
    fmt.Println("当前时间戳: ", current)

根据 Go 官方文档,.Unix() 返回一个整数表示自 Unix 纪元以来的秒数。

自从1970年1月1日UTC起过去的秒数。

这显示在终端上:

现在的時間戳是 1733691162

这算是个不错的开端,但我们再运行一遍那段代码,时间戳的值会改变,而我们希望它在这30秒内保持稳定。好吧,这小菜一碟,我们可以把它除以30,然后用这个结果作为基础。

    // 获取当前时间戳
    current := time.Now().Unix()
    fmt.Println("当前时间戳:", current)

    // 获取一个在30秒内保持不变的数值,这样可以确保在短时间内值的一致性
    base := current / 30
    fmt.Println("基准值:", base)

我们来运行它。

    当前时戳: 1733691545  
    基准值: 57789718

再来一次:

当前时间戳: 1733691552  
基准值: 57789718

基础值还是原来的。咱们先等会儿,再试一次运行。

    当前时间戳: 1733691571。  
    基础值: 57789719。

基础值已经发生变化,因为30秒的时间窗口已经过去——做得不错!

如果“除以30”的逻辑你不明白,让我用一个简单的例子来给你解释一下:

  • 假设我们的时间戳返回 1,比如
  • 如果我们将 1 除以 30,结果将会是 0,因为在严格类型的语言里,整数除以整数得到的仍然是整数,不会关心小数部分
  • 这意味着在时间戳从 0 到 29 的这 30 秒内,我们得到的都是 0
  • 一旦时间戳达到 30,除法结果就是 1,直到 60(结果就变成 2),以此类推

现在你是不是觉得更明白了?

然而,并非所有的要求都已满足,因为我们需要一个六位数的结果,而目前的基数是8位数,以此类推。好吧,我们再用一个简单的数学技巧:我们把基数除以1 000 000,然后取余数,这样得到的余数总是恰好有6位数,因为余数可以是0到999 999之间的任意数,但永远不会超过这个范围。

    // 获取当前的时间戳:
    current := time.Now().Unix()
    fmt.Println("当前时间戳:", current)
    ​
    // 获取一个在30秒内保持稳定的数字:
    base := current / 30
    fmt.Println("基础:", base)
    ​
    // 保证该数字只有6位:
    code := base % 1_000_000
    ​
    // 如果需要,添加前导零:
    formattedCode := fmt.Sprintf("%06d", code)
    fmt.Println("编码:", formattedCode)

fmt.Sprintf("%06d", code) 这一部分会在代码值少于 6 位时在其前面补零。例如,1234 会被转换为 001234。该帖子的完整代码可以在这里查看 此处

我们来运行这段代码吧:

    当前时间戳: 1733692423  
    基准值: 57789747  
    代码: 789747

好了,我们得到了6位数的代码,耶!但是这里有些不对劲的地方,对吧?如果我给你这个代码,你和我同时使用它,你也会得到同样的代码,对吧?这并不能保证是一个安全的一次性密码,对吗?这里有一个新的要求来了:

结果应该会根据不同的用户而有所变化

当然,如果用户数量超过100万,因为六位数所能表示的最大唯一值有限,一些碰撞是不可避免的。但这些碰撞很少见,从技术角度不可避免,而不是我们现在遇到的那种算法设计缺陷。

我认为单靠一些巧妙的数学方法在这里帮不上忙:如果我们需要为每个用户单独的结果,我们需要一个特定于用户的状态来实现这一点。作为工程师,我们同时也是许多服务的用户,我们知道为了访问它们的API接口,服务依赖于每个用户独有的私钥。我们也可以为这个用例引入一个私钥来区分各个用户。

私钥

生成私钥的简单逻辑,比如说,是将私钥生成为介于1 000 000和999 999 999之间的整数。

    var pkDb = make(map[int]bool)  

    func main() {  
     prForUser := nextPrivateKey()  
     fmt.Println("用户的私钥是:", prForUser)  
    }  

    func nextPrivateKey() int {  
     r := randomPrivateKey()  
     for pkDb[r] {  
      r = randomPrivateKey()  
     }  
     pkDb[r] = true  
     return r  
    }  

    // 生成一个介于1,000,000到999,999,999之间的随机数  
    func randomPrivateKey() int {  
     return rand.Intn(999_999_999-1_000_000) + 1_000_000  
    }

我们利用 pkDb 映射来避免私钥重复,一旦发现重复,我们将重新运行生成逻辑,直到获得一个独一无二的结果。

下面我们就来运行这段代码以获取私钥:

用户ID私有密钥: 115537846

我们可以利用这个私钥在代码生成逻辑中,确保每个私钥都能得到不同的结果。因为我们私钥是整数类型,最简单的方法是将其加到基础值上,同时保持其余部分的算法不变。

    func generateCode(pk int64) string {  
     // 获取当前时间戳:  
     current := time.Now().Unix()  
     fmt.Println("当前时间戳: ", current)  

     // 获取一个在30秒内保持不变的数字:  
     base := current / 30 + pk  
     fmt.Println("基准值: ", base)  

     // 确保它只有6位数字:  
     code := base % 1_000_000  

     // 如果不足六位,则添加前导零:  
     return fmt.Sprintf("%06d", code)  
    }

我们要保证不同的私钥带来不同的结果。

    var pkForUser1 int64 = 115537846  
    var pkForUser2 int64 = 715488689  

    codeForUser1 := generateCode(pkForUser1)  
    fmt.Println("用户1的代码是: ", codeForUser1)  

    fmt.Println()  

    codeForUser2 := generateCode(pkForUser2)  
    fmt.Println("用户2的代码是: ", codeForUser2)

结果和我们预想的一样。

    当前时间戳: 1733695429  
    基础值: 173327693  
    用户1的编号: 327693  

    当前时间戳: 1733695429  
    基础值: 773278536  
    用户2的编号: 278536

简直是神来之笔!这意味着私钥应该在生成验证码发给像我这样的用户之前被注入到设备里:这应该不会给银行带来困扰。

我们现在可以结束了么?好吧,只要我们对这个人工设置满意。如果你曾经为任何你有账户的服务/网站启用了多因素认证,你可能会发现该网站会要求你用你选择的第二因素身份验证应用(如Authy、Google 身份验证器、2FAS等)扫描二维码,该应用会从这时开始生成六位数的验证码。或者你可以直接手动输入验证码。

我要提到的是,可以看一下行业内实际使用的私钥格式。这些私钥通常长度在16到32个字符之间,是Base32编码的字符串,看起来类似这样:

JBSWY3DPEB2GQZLSMUQQ

正如你所看到的,这与我们使用的整数私钥(integer private key)有很大不同,如果切换到这种格式,当前的算法将无法运行。我们该如何调整我们的逻辑?

私钥(字符串)

让我们从一个简单的办法开始:我们的代码因为这一行无法编译。

base = 当前 / 30 + pk

既然 pk 现在变成了 string 类型,我们为什么不把它转换成整数呢?虽然有更优雅和高效的方法,但这是我想到的最简单的方法,如下所示:

// 将字符串 pk 进行哈希处理
func hash(pk string) int64 {  
 var 哈希值 int64 = 0  
 for _, char := range pk {  
  哈希值 = 31*哈希值 + int64(char)  
 }  
 return 哈希值  
}

这受到了Java String数据类型中hashCode()方法的启发,在我们的场景中已经足够用了。

这里的逻辑已经调整如下:

    func main() {  
     var pkForUser1 int64 = 115537846  
     var pkForUser2 int64 = 715488689  

     codeForUser1 := generateCode(pkForUser1)  // codeForUser1 := generateCode(pkForUser1)
     fmt.Println("用户1的代码: ", codeForUser1)  

     fmt.Println()  

     codeForUser2 := generateCode(pkForUser2)  // codeForUser2 := generateCode(pkForUser2)
     fmt.Println("用户2的代码: ", codeForUser2)  
    }  

    func generateCode(pk int64) string {  // 生成代码函数(传入一个int64类型的pk参数并返回一个字符串)
     // 获取当前时间戳:  
     current := time.Now().Unix()  
     fmt.Println("当前时间戳: ", current)  

     // 获取一个在30秒内保持稳定的数字:  
     base := current / 30 + pk  
     fmt.Println("基数: ", base)  

     // 确保它只有6位数字:  
     code := base % 1_000_000  

     // 如果必要则添加前导零:  
     return fmt.Sprintf("%06d", code)  
    }

以下是终端输出:

[终端输出内容]
    当前的时间戳: 1733771953  
    值: 9056767456302232090  
    编号: 232090

挺好的,验证码有了,做得不错。我们等等到下一个时间段再试一次。

    当前时间戳: 1733771973  
    基数: 9056767456302232091  
    代码: 232091

这个方法虽然能用,但存在一些问题,因为代码基本上是前一个值的递增,这样做不太好,因为这样一次性密码(OTP,One-Time Password)就变得可预测了。知道某个时间点的值,就可以非常容易地开始生成相同的值,而无需知道私钥。下面是一个简单的伪代码示例,用于说明这个漏洞:

    now = currentTimestamp()  
    diff = now - oldOtpTimestamp  
    numOfOtpsIssuedSinceThen = diff / 30  
    currentOtp = 计算并限制在六位数内(oldOtp + numOfOtpsIssuedSinceThen)

keepWithinSixDigits 将确保在 999 999 之后的值变为 000 000,以此确保数值始终处于六位数的范围内。

正如你所见,这是一个严重的安全漏洞。为什么会这样?如果我们来看看base的计算逻辑,你会发现它依赖于两个因素。

  • 当前时间戳除以30的结果
  • 私钥的哈希

哈希对于相同的键会产生相同的值,因此其值是恒定的。至于 current / 30,它在30秒内具有相同的值,但一旦窗口过去,下一个值将会是前一个值的增加。然后 base % 1_000_000 就表现出我们预期的行为。我们之前的实现(将私钥作为整数来处理)也有相同的漏洞,但我们没有注意到这一点——测试不足是造成这一问题的原因。

我们需要以某种方式处理 current / 30,让它值的变化更加明显。

分布式一次性密码

有多种方法可以做到这一点,也有一些有趣的数学技巧,但为了教育目的,让我们优先考虑解决方案的可读性,我们将 current / 30 提取到单独的变量 base 中,并将该变量包含在哈希计算逻辑中:

base := current / 30

// 将 base 和 pk 转换为一个数
func hash(base int64, pk string) int64 {
 num := base
 for _, char := range pk {
  num = 31 * num + int64(char)
 }
 fmt.Println("哈希:", num)
 return num
}

这样一来,即使基础每30秒增加1,在经过hash()函数处理后,因为一系列的乘法,diff的权重依然会增加因此。

这是一个更新后的代码示例:

func main() {  
 pk := "JBSWY3DPEB2GQZLSMUQQ"  

 codeForUser1 := generateCode(pk)  
 fmt.Println("Code: ", codeForUser1)  
}  

func generateCode(pk string) string {  
 // 获取当前的时间戳:  
 current := time.Now().Unix()  
 fmt.Println("当前时间戳: ", current)  

 // 获取一个在30秒内保持不变的数字:  
 base := current / 30  

 // 将基础数值与私钥结合,得到用户的唯一数字:  
 baseWithPk := hash(base, pk)  
 fmt.Println("基础数值: ", baseWithPk)  

 // 确保结果是6位数:  
 code := baseWithPk % 1_000_000  

 // 必要时添加前导零:  
 return fmt.Sprintf("%06d", code)  
}  

// 将基础值和私钥转化为数字  
func hash(base int64, pk string) int64 {  
 num := base  
 for _, char := range pk {  
  num = 31 * num + int64(char)  
 }  
 fmt.Println("哈希值: ", num)  
 return num  
}

我们来运行它:

    当前时间戳: 1733773490  
    哈希: -1073062424175310899  
    基数值: -1073062424175310899  
    代码: -310899

Boom!砰!我们为什么会在这里得到负值呢?看来我们超出了64位整数的范围,所以数值被限制为最小值,并重新开始——我的Java朋友们对此应该从hashCode()的行为中很熟悉。解决方法很简单:我们只需要将结果取绝对值,这样就忽略了负号。

    // 将基础值与私钥结合以获得用户特有的唯一数字:
    baseWithPk := hash(base, pk)

    // 确保结果是正的,
    absBaseWithPk := int64(math.Abs(float64(baseWithPk)))

以下是一个包含修复措施的完整代码示例:

    func main() {  
     pk := "JBSWY3DPEB2GQZLSMUQQ"  

     codeForUser1 := generateCode(pk)  
     fmt.Println("Code: ", codeForUser1)  
    }  

    func generateCode(pk string) string {  
     // 获取当前的时间戳:  
     current := time.Now().Unix()  
     fmt.Println("当前时间戳:", current)  

     // 获取一个在30秒内保持稳定的数值:  
     base := current / 30  

     // 将基础值与私钥结合生成用户的唯一标识:  
     baseWithPk := hash(base, pk)  

     // 确保结果为正数:  
     absBaseWithPk := int64(math.Abs(float64(baseWithPk)))  
     fmt.Println("基础值:", absBaseWithPk)  

     // 确保结果是一个6位数:  
     code := absBaseWithPk % 1_000_000  

     // 如果需要,补足前导零:  
     return fmt.Sprintf("%06d", code)  
    }  

    // 将基础值和私钥转换成数字  
    func hash(base int64, pk string) int64 {  
     num := base  
     for _, char := range pk {  
      num = 31 * num + int64(char)  
     }  
     fmt.Println("哈希结果:", num)  
     return num  
    }  

咱们来运行它。

    当前时间戳: 1733774391  
    哈希值: 3581577654419246315  
    基础值: 3581577654419246080  
    代码值: 246080

我们再运行一次,确保OTP值已经分配好了。

    当前时间戳:1733774402  
    哈希码: 4351623792829383276  
    基础值: 4351623792829383168  
    代码: 383168

好啦,终于有一个靠谱的解决办法了!

实际上,那就是我停止手动实现的时候,因为我从中得到了乐趣并学到了新东西。然而,这既不是最佳方案,也不是我会上线使用的。其中一个问题是,像你看到的,由于哈希逻辑和时间戳值的存在,我们的逻辑总是处理大数字,这意味着我们几乎不可能生成以1或多个0开头的数字:例如,012345001234等,尽管这些是完全有效的。因此,我们少了10万种可能的值,这相当于算法可能结果的10%——这意味着碰撞的概率更高。这真的很不酷!

从这里去哪里

我不会深入介绍实际应用中使用的技术实现,但是对于好奇的人来说,我会推荐两个值得一看的RFC:

以下是根据上述RFC规范实现的伪代码:

注:RFC是指请求评论的文件,用于规范互联网标准。

    定义生成TOTP函数(密钥):  
        当前时间戳 = 时间.now().unix()  
        计数器 = floor(当前时间戳 / 30)  
        hmac = HMAC-SHA1(密钥, 计数器)  
        偏移量 = hmac[-1] & 0x0F # 获取最后一位的低4位  
        截断哈希 = hmac[偏移量:偏移量+4] # 提取4个字节  
        二进制值 = (截断哈希 & 0x7FFFFFFF)  
        otp = (二进制值 % 1 000 000) # 减少到所需长度  
        return otp

正如你所见,我们已经非常接近了,但原来的算法使用了更高级的哈希算法(例如 HMAC-SHA1),并进行了一些位操作以规范输出。

安全方面的考虑:尽量重用而不是自己构建

不过,在我们今天结束之前,还有一件事要提,那就是安全。我特别建议你别自己搞生成一次性密码(OTP)的逻辑,因为外面有很多库已经把这事做好了,你直接用就行了。出错的可能性很大,离被黑客发现并利用的漏洞也不远。

即使你搞对了生成逻辑并且准备用测试来覆盖它,还是可能会遇到其他问题。例如,你觉得暴力破解一个六位数的密码需要多少时间呢?我们来试一试。

    func main() {  
     pk := "JBSWY3DPEB2GQZLSMUQQ"  

     code := generateCode(pk)  
     fmt.Println("Code: ", code)  

     // 使用暴力破解查找OTP并测量查找所需的时间(以毫秒为单位):  
     start := time.Now()  
     for i := 0; i < 1000000; i++ {  
      if fmt.Sprintf("%06d", i) == strconv.FormatInt(code, 10) {  
       fmt.Println("Found: ", i)  
       finish := time.Now()  
       fmt.Println("时间: ", finish.Sub(start).Milliseconds(), "毫秒")  
       break  
      }  
     }  
    }  

    func generateCode(pk string) int64 {  
     // 获取当前时间戳值:  
     current := time.Now().Unix()  

     // 获取一个在30秒内保持稳定的数字:  
     base := current/30 + 哈希(pk)  

     // 确保结果为六位数:  
     return base % 1000000  
    }  

    // 将pk字符串转换为数字  
    func 哈希(pk string) int64 {  
     var num int64  
     for _, c := range pk {  
      num += int64(c)  
     }  
     return num  
    }

我们来运行这段代码怎么样:

    代码: 661504  
    发现: 661504  
    时间: 75 毫秒

再说一次:

    Code: 524928  
    Found: 524928  
    Time: 67毫秒

如你所见,通过简单的暴力循环猜测验证码大约需要70毫秒左右。这比一次性密码(OTP)的有效期快400多倍之快!使用一次性密码(OTP)机制的应用或网站的服务器需要通过一些措施来防止这种情况,例如在三次失败尝试后,在接下来的5或10秒内不接受新验证码。这样,攻击者在30秒的时间窗口内只能进行18次或9次验证码尝试,这对包含100万个可能值的池来说是不够的。

还有许多类似的小事常常被忽略。让我再强调一下:别自己从头开始搞,而是直接用现成的就好。

无论如何,我希望你今天学到了一些新东西,从此你不会再对OTP逻辑感到困惑。另外,如果你在未来的某个时刻需要让你的离线设备按照一个可重复的算法生成一些值,你现在知道怎么开始了。

感谢你花时间阅读这篇帖子,祝你愉快!,玩得开心!=)

附注:每次我发布新文章时,你就会点击这里订阅

顺便说一句,跟其他酷的人一样,我最近也创建了一个 Bluesky 账号,所以请大家一起帮我让我的 feed 更有趣一些 =)

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消