搜索
首页后端开发Golang揭秘 OTP:离线生成代币背后的逻辑

你好!另一个晚上,在回家的路上,我决定检查一下邮箱。我指的不是我的电子邮件收件箱,而是邮递员放置实际信件的老式实际盒子。令我惊讶的是,我在那里发现了一个信封,里面装着一些东西!当我打开它时,我花了一些时间希望这是来自霍格沃茨的迟来了几十年的信。但当我注意到这是一封来自银行的无聊的“成人”信时,我不得不回到现实。我浏览了一下文字,意识到我的“酷孩子纯数字”银行已被当地市场上最大的玩家收购。作为新开始的象征,他们在信封上添加了以下内容:

Demystifying OTPs: the logic behind the offline generation of tokens

以及如何使用它的说明。

如果你像我一样,从来没有遇到过这样的技术创新,让我分享一下我从信中学到的东西:新所有者决定执行他们公司的安全策略,这意味着所有用户从现在开始,帐户将启用 MFA(顺便说一句,这是值得称赞的)。您在上面看到的设备会生成 6 位数字长的一次性令牌,在登录您的银行帐户时用作第二因素。基本上,与 Authy、Google Authenticator 或 2FAS 等应用程序的工作方式相同,但采用物理形状。

所以,我尝试了一下,登录过程很顺利:设备向我显示了一个 6 位代码,我在我的银行应用程序中输入了它,这让我进入了。万岁!但后来我突然意识到:这东西是如何工作的?它无法以某种方式连接到互联网,但它设法生成我的银行服务器接受的正确代码。嗯...里面有SIM卡或者类似的东西吗?没办法!

意识到我的生活将永远不一样,我开始想知道我上面提到的应用程序(Authy 和朋友)?我内心的研究员被唤醒了,所以我将手机切换到飞行模式,令我大吃一惊的是,我意识到它们在离线状态下工作得非常好:它们不断生成被应用程序服务器接受的代码。有趣!

不确定你的看法,但我一直认为一次性代币流是理所当然的,并且从未真正认真考虑过它(特别是因为现在我的手机很少没有互联网,除非我正在做一些户外探险),所以这是我惊讶的根本原因。否则,从安全的角度来看,以这种方式工作是完全有意义的,因为生成过程纯粹是本地的,因此对于外部参与者来说是安全的。但它是如何运作的呢?

嗯,像 Google 或 ChatGPT 这样的现代技术可以让您轻松找到答案。但这个技术问题对我来说似乎很有趣,所以决定先尝试一下并自己解决。

要求

让我们从我们拥有的开始:

  • 可生成 6 位代码的离线设备
  • 服务器接受这些代码,验证它们,如果正确则发出绿色信号

服务器验证部分提示服务器必须能够生成与离线设备相同的代码来比较它们。嗯..这会很有帮助。

对我的新“玩具”的进一步观察带来了更多发现:

  • 如果我将其关闭然后再关闭,通常我可以看到与之前相同的代码
  • 然而,偶尔,它会改变

我能想到的唯一逻辑解释是这些代码有一定的生命周期。我想讲述一个我试图以“1-2-3-...-N”方式计算它的持续时间的故事,但这不会是真的:我从像这样的应用程序中得到了一个很大的提示Authy and Co,我在那里看到了 30 秒的 TTL。很好的发现,让我们将其添加到已知事实列表中。

让我们总结一下到目前为止我们的要求:

  • 以 6 位数字格式生成可预测(而不是随机)的代码
  • 生成逻辑应该是可重现的,这样无论平台如何,都可以得到相同的结果
  • 代码生命周期为 30 秒,这意味着在这段时间内生成算法会产生相同的值

大问题

好吧,但主要问题仍然没有得到解答:离线应用程序如何生成与其他应用程序中的值相匹配的值?他们有什么共同点?

如果您喜欢《指环王》宇宙,您可能还记得比尔博如何与咕噜玩谜语游戏,并解决了这个问题:

这个万物吞噬之物:
鸟、兽、树、花;
啃铁咬钢;
将坚硬的石头磨成粉;
杀死国王,摧毁城镇,
并击败高山。

剧透警告,但巴金斯先生很幸运,意外地得出了正确答案 - “时间!”。不管你相信与否,这也正是我们谜题的答案:任何 2 个(或更多)应用程序都可以访问同一时间,只要它们内部有嵌入式时钟。如今,后者已不再是问题,而且所讨论的设备足够大,可以容纳它。环顾四周,你的手表、手机、电视、烤箱和墙上的时钟上的时间很可能是相同的。我们在这里很感兴趣,似乎我们已经找到了 OTP(一次性密码)计算的基础!

挑战

依赖时间有其自身的一系列挑战:

  • 时区 - 使用哪一个?
  • 时钟往往会不同步,这对分布式系统来说是一个巨大的挑战

让我们一一解决:

  • 时区:这里最简单的解决方案是确保所有设备都依赖于同一区域,并且 UTC 可以是一个很好的与位置无关的候选
  • 至于时钟不同步:实际上,我们甚至可能不需要解决它,而是接受不可避免的事情,只要漂移在一两秒之内,考虑到 30 秒的 TTL,这可能是可以容忍的。设备的硬件生产商应该能够预测何时会实现这种漂移,因此设备将使用它作为其到期日期,而银行将简单地用新的替换它们,或者将有办法连接它们到网络来校准时钟。至少,这是我的想法。

执行

好的,这件事已经解决了,所以让我们尝试以时间为基础来实现我们算法的第一个版本。由于我们对 6 位数字结果感兴趣,因此依赖时间戳而不是人类可读的日期听起来是一个明智的选择。让我们从这里开始:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

根据 Go 文档,.Unix() 返回

自 UTC 1970 年 1 月 1 日以来经过的秒数。

这是终端打印的内容:

Current timestamp:  1733691162

这是一个好的开始,但是如果我们重新运行该代码,时间戳值将会改变,而我们希望保持它稳定 30 秒。好吧,小菜一碟,我们将其除以 30 并使用该值作为基数:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)

让我们运行它:

Current timestamp:  1733691545
Base:  57789718

再说一遍:

Current timestamp:  1733691552
Base:  57789718

基础值保持不变。让我们稍等一下,然后再次运行:

Current timestamp:  1733691571
Base:  57789719

随着 30 秒窗口的过去,基础值已更改 - 干得好!

如果“除以30”的逻辑没有意义,让我用一个简单的例子来解释一下:

  • 想象一下我们的时间戳返回 1
  • 如果我们将 1 除以 30,结果将为 0,就像当我们使用严格类型编程语言时,整数除以整数会返回另一个整数,该整数与浮点部分无关
  • 这意味着在接下来的 30 秒内,当时间戳介于 0 到 29 之间时,我们将得到 0
  • 一旦时间戳达到30,除法的结果就是1,直到60(变成2),依此类推

我希望它现在更有意义。

但是,还没有满足所有要求,因为我们需要 6 位数字的结果,而当前基数截至今天是 8 位数字,但在未来的某个时间点可能会达到 9 位数字点,依此类推。好吧,让我们使用另一个简单的数学技巧:将基数除以 1 000 000,并得到余数,余数始终为 6 位数字,因为提醒可以是 0 到 999 999 之间的任何数字,但不能更大:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

fmt.Sprintf(" d", code) 部分会附加前导零,以防我们的代码值少于 6 位。例如,1234 将转换为 001234。
这篇文章的完整代码可以在这里找到。

让我们运行此代码:

Current timestamp:  1733691162

好的,我们得到了 6 位代码,万岁!但这里感觉有些不对劲,不是吗?如果我给您这个代码,并且您将与我同时运行它,您将获得与我相同的代码。这并不意味着它是一个安全的一次性密码,对吧?新的要求来了:

  • 不同用户的结果应该不同

当然,如果我们的用户超过 100 万,一些冲突是不可避免的,因为这是每 6 位数字的最大可能唯一值。但这些都是罕见的,技术上不可避免的碰撞,而不是像我们现在这样的算法设计缺陷。

我认为任何聪明的数学技巧本身都不会帮助我们:如果我们需要每个用户单独的结果,我们需要一个特定于用户的状态来实现它。作为工程师,同时也是许多服务的用户,我们知道要授予对其 API 的访问权限,服务依赖于私钥,而私钥对于每个用户来说都是唯一的。让我们为我们的用例引入一个私钥,以区分用户。

私钥

生成 1 000 000 到 999 999 999 之间整数的私钥的简单逻辑:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)

我们使用 pkDb 映射作为防止私钥之间重复的方法,如果检测到重复,我们将再次运行生成逻辑,直到获得唯一的结果。

让我们运行此代码来获取私钥示例:

Current timestamp:  1733691545
Base:  57789718

让我们在代码生成逻辑中使用此私钥,以确保每个私钥得到不同的结果。由于我们的私钥是整数类型,所以我们能做的最简单的事情就是将其添加到基值上,并保持其余算法不变:

Current timestamp:  1733691552
Base:  57789718

让我们确保它对于不同的私钥产生不同的结果:

Current timestamp:  1733691571
Base:  57789719

结果看起来符合我们的期望:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds:
base := current / 30
fmt.Println("Base: ", base)

// makes sure it has only 6 digits:
code := base % 1_000_000

// adds leading zeros if necessary:
formattedCode := fmt.Sprintf("%06d", code)
fmt.Println("Code: ", formattedCode)

魅力十足!这意味着私钥应该先注入生成代码的设备中,然后再发送给像我这样的用户:这对银行来说根本不应该是问题。

我们现在完成了吗?好吧,前提是我们对我们使用的人工场景感到满意。如果您曾经为您拥有帐户的任何服务/网站启用过 MFA,您可能会注意到网络资源要求您使用您选择的第二因素应用程序(Authy、Google Authenticator、2FAS 等)扫描 QR 码。 ),它将把密码输入到您的应用程序中,并从那时起开始生成 6 位数字的代码。或者,也可以手动输入代码。

我提出这个是为了一睹业界使用的真实私钥的格式。它们通常是 16-32 个字符长的 Base32 编码字符串,如下所示:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

如您所见,这与我们使用的整数私钥有很大不同,如果我们要切换到这种格式,我们算法的当前实现将无法工作。我们如何调整我们的逻辑?

私钥作为字符串

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

Current timestamp:  1733691162

因为 pk 从现在开始是字符串类型。那么我们为什么不把它转换成整数呢?虽然有更优雅和更高效的方法可以做到这一点,但这是我想到的最简单的方法:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)

这很大程度上受到 String 数据类型的 Java hashCode() 实现的启发,这使得它足以满足我们的场景。

调整后的逻辑如下:

Current timestamp:  1733691545
Base:  57789718

这是终端输出:

Current timestamp:  1733691552
Base:  57789718

很好,有 6 位代码,干得好。让我们等待到达下一个时间窗口并再次运行它:

Current timestamp:  1733691571
Base:  57789719

嗯...它可以工作,但是代码基本上是前一个值的增量,这不好,因为这样 OTP 是可预测的,并且拥有它的值并知道它属于什么时间,这是非常好的无需知道私钥即可开始生成相同的值。这是此黑客攻击的简单伪代码:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds:
base := current / 30
fmt.Println("Base: ", base)

// makes sure it has only 6 digits:
code := base % 1_000_000

// adds leading zeros if necessary:
formattedCode := fmt.Sprintf("%06d", code)
fmt.Println("Code: ", formattedCode)

其中 keepWithinSixDigits 将确保在 999 999 之后下一个值是 000 000 等等,以将该值保持在 6 位数字的限制范围内。

如您所见,这是一个严重的安全漏洞。为什么会发生这种情况?如果我们看一下基本计算逻辑,我们会发现它依赖于两个因素:

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

哈希对于相同的键产生相同的值,因此它的值是恒定的。至于当前的 / 30 ,它在 30 秒内具有相同的值,但是一旦窗口过去,下一个值将是前一个值的增量。然后 base % 1_000_000 的行为就像我们看到的那样。我们之前的实现(使用私钥作为整数)也有同样的漏洞,但我们没有注意到这一点 - 缺乏测试。

我们需要将 current / 30 转换成某种东西,使其值的变化更加明显。

分布式 OTP 值

有多种方法可以实现这一点,并且存在一些很酷的数学技巧,但出于教育目的,让我们优先考虑我们将采用的解决方案的可读性:让我们将 current / 30 提取到一个单独的变量基数中并包含进入哈希计算逻辑:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

这样,即使底数每 30 秒变化 1,但在 hash() 函数逻辑中使用后,diff 的权重会因为一系列乘法的执行而增加。

这是更新后的代码示例:

Current timestamp:  1733691162

让我们运行它:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)

繁荣!我们怎么会在这里得到负值呢?好吧,看起来我们已经超出了 int64 范围,因此它将值限制为负数并重新开始 - 我的 Java 同事通过 hashCode() 行为对此很熟悉。 解决方法很简单:让我们从结果中取绝对值,这样减号就被忽略:

Current timestamp:  1733691545
Base:  57789718

这是修复后的完整代码示例:

Current timestamp:  1733691552
Base:  57789718

让我们运行它:

Current timestamp:  1733691571
Base:  57789719

让我们再次运行它以确保 OTP 值现在已分发:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds:
base := current / 30
fmt.Println("Base: ", base)

// makes sure it has only 6 digits:
code := base % 1_000_000

// adds leading zeros if necessary:
formattedCode := fmt.Sprintf("%06d", code)
fmt.Println("Code: ", formattedCode)

很好,终于有了一个不错的解决方案!

实际上,那是我停止手动实施过程的那一刻,因为我享受到了乐趣并学到了新东西。然而,这既不是最好的解决方案,也不是我愿意接受的解决方案。除此之外,它还有一个很大的缺陷:如您所见,由于哈希逻辑和时间戳值,我们的逻辑总是处理大数字,这意味着我们不太可能生成以 1 或 1 开头的结果。更多零:例如 012345 、 001234 等,即使它们完全有效。因此,我们还缺少 100 000 个可能的值,这比算法的可能结果数量少了 10% - 这样碰撞的可能性会更高。不酷!

从这里到哪里去

我不会深入探讨实际应用程序中使用的实现,但对于那些好奇的人,我将分享两个值得一看的 RFC:

  • HOTP:一种基于 HMAC 的一次性密码算法
  • TOTP:基于时间的一次性密码算法

这是伪代码实现,它将根据上述 RFC 以预期方式工作:

Current timestamp:  1733692423
Base:  57789747
Code:  789747

如您所见,我们已经非常接近了,但原始算法使用更高级的哈希(本例中为 HMAC-SHA1),并执行一些按位运算来标准化输出。

安全考虑:重用而不是自己构建

但是,在我们结束之前我还想讲一件事:安全性。我强烈建议您不要自行实现生成 OTP 的逻辑,因为有很多库已经为我们完成了这一工作。犯错的空间是巨大的,而且离不良行为者发现和利用的漏洞只有很短的距离。

即使您的生成逻辑正确并通过测试覆盖它,也可能会出现其他问题。例如,您认为暴力破解 6 位代码需要花费多少时间?让我们来实验一下:

// gets current timestamp:
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

让我们运行此代码:

Current timestamp:  1733691162

再一次:

// gets current timestamp
current := time.Now().Unix()
fmt.Println("Current timestamp: ", current)

// gets a number that is stable within 30 seconds
base := current / 30
fmt.Println("Base: ", base)

如您所见,通过简单的暴力 for 循环猜测代码大约需要 70 毫秒。这比 OTP 的寿命快 400 倍!使用 OTP 机制的应用程序/网站的服务器需要通过例如在 3 次失败尝试后的接下来 5 或 10 秒内不接受新代码来防止这种情况。这样,攻击者在 30 秒的窗口内只能相应地获得 18 或 9 次尝试,这对于 100 万个可能值的池来说是不够的。

还有其他类似的事情很容易被忽视。所以,让我再说一遍:不要从头开始构建它,而是依赖现有的解决方案。

无论如何,我希望你今天学到了一些新东西,从现在开始 OTP 逻辑对你来说不再是一个谜。此外,如果在生活中的某个时刻,您需要使用可重现的算法让离线设备生成一些值,那么您很清楚从哪里开始。

感谢您花时间阅读这篇文章,祝您玩得开心! =)

P.S.我发布新帖子后会收到一封电子邮件 - 在此订阅

P.P.S.和其他酷孩子一样,我最近创建了一个 Bluesky 帐户,所以请帮助我让我的 Feed 变得更有趣 =)

以上是揭秘 OTP:离线生成代币背后的逻辑的详细内容。更多信息请关注PHP中文网其他相关文章!

声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
Go语言包导入:带下划线和不带下划线的区别是什么?Go语言包导入:带下划线和不带下划线的区别是什么?Mar 03, 2025 pm 05:17 PM

本文解释了GO的软件包导入机制:命名imports(例如导入“ fmt”)和空白导入(例如导入_ fmt; fmt;)。 命名导入使包装内容可访问,而空白导入仅执行t

Beego框架中NewFlash()函数如何实现页面间短暂信息传递?Beego框架中NewFlash()函数如何实现页面间短暂信息传递?Mar 03, 2025 pm 05:22 PM

本文解释了Beego的NewFlash()函数,用于Web应用程序中的页间数据传输。 它专注于使用newflash()在控制器之间显示临时消息(成功,错误,警告),并利用会话机制。 Lima

Go语言中如何将MySQL查询结果List转换为自定义结构体切片?Go语言中如何将MySQL查询结果List转换为自定义结构体切片?Mar 03, 2025 pm 05:18 PM

本文详细介绍了MySQL查询结果的有效转换为GO结构切片。 它强调使用数据库/SQL的扫描方法来最佳性能,避免手动解析。 使用DB标签和Robus的结构现场映射的最佳实践

如何编写模拟对象和存根以进行测试?如何编写模拟对象和存根以进行测试?Mar 10, 2025 pm 05:38 PM

本文演示了创建模拟和存根进行单元测试。 它强调使用接口,提供模拟实现的示例,并讨论最佳实践,例如保持模拟集中并使用断言库。 文章

如何定义GO中仿制药的自定义类型约束?如何定义GO中仿制药的自定义类型约束?Mar 10, 2025 pm 03:20 PM

本文探讨了GO的仿制药自定义类型约束。 它详细介绍了界面如何定义通用功能的最低类型要求,从而改善了类型的安全性和代码可重复使用性。 本文还讨论了局限性和最佳实践

Go语言如何便捷地写入文件?Go语言如何便捷地写入文件?Mar 03, 2025 pm 05:15 PM

本文详细介绍了在GO中详细介绍有效的文件,将OS.WriteFile(适用于小文件)与OS.openfile和缓冲写入(最佳大型文件)进行比较。 它强调了使用延迟并检查特定错误的可靠错误处理。

您如何在GO中编写单元测试?您如何在GO中编写单元测试?Mar 21, 2025 pm 06:34 PM

本文讨论了GO中的编写单元测试,涵盖了最佳实践,模拟技术和有效测试管理的工具。

如何使用跟踪工具了解GO应用程序的执行流?如何使用跟踪工具了解GO应用程序的执行流?Mar 10, 2025 pm 05:36 PM

本文使用跟踪工具探讨了GO应用程序执行流。 它讨论了手册和自动仪器技术,比较诸如Jaeger,Zipkin和Opentelemetry之类的工具,并突出显示有效的数据可视化

See all articles

热AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover

AI Clothes Remover

用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool

Undress AI Tool

免费脱衣服图片

Clothoff.io

Clothoff.io

AI脱衣机

AI Hentai Generator

AI Hentai Generator

免费生成ai无尽的。

热门文章

R.E.P.O.能量晶体解释及其做什么(黄色晶体)
2 周前By尊渡假赌尊渡假赌尊渡假赌
仓库:如何复兴队友
1 个月前By尊渡假赌尊渡假赌尊渡假赌
Hello Kitty Island冒险:如何获得巨型种子
4 周前By尊渡假赌尊渡假赌尊渡假赌

热工具

EditPlus 中文破解版

EditPlus 中文破解版

体积小,语法高亮,不支持代码提示功能

安全考试浏览器

安全考试浏览器

Safe Exam Browser是一个安全的浏览器环境,用于安全地进行在线考试。该软件将任何计算机变成一个安全的工作站。它控制对任何实用工具的访问,并防止学生使用未经授权的资源。

螳螂BT

螳螂BT

Mantis是一个易于部署的基于Web的缺陷跟踪工具,用于帮助产品缺陷跟踪。它需要PHP、MySQL和一个Web服务器。请查看我们的演示和托管服务。

SublimeText3 英文版

SublimeText3 英文版

推荐:为Win版本,支持代码提示!

SublimeText3 Mac版

SublimeText3 Mac版

神级代码编辑软件(SublimeText3)