你好!另一个晚上,在回家的路上,我决定检查一下邮箱。我指的不是我的电子邮件收件箱,而是邮递员放置实际信件的老式实际盒子。令我惊讶的是,我在那里发现了一个信封,里面装着一些东西!当我打开它时,我花了一些时间希望这是来自霍格沃茨的迟来了几十年的信。但当我注意到这是一封来自银行的无聊的“成人”信时,我不得不回到现实。我浏览了一下文字,意识到我的“酷孩子纯数字”银行已被当地市场上最大的玩家收购。作为新开始的象征,他们在信封上添加了以下内容:
以及如何使用它的说明。
如果你像我一样,从来没有遇到过这样的技术创新,让我分享一下我从信中学到的东西:新所有者决定执行他们公司的安全策略,这意味着所有用户从现在开始,帐户将启用 MFA(顺便说一句,这是值得称赞的)。您在上面看到的设备会生成 6 位数字长的一次性令牌,在登录您的银行帐户时用作第二因素。基本上,与 Authy、Google Authenticator 或 2FAS 等应用程序的工作方式相同,但采用物理形状。
所以,我尝试了一下,登录过程很顺利:设备向我显示了一个 6 位代码,我在我的银行应用程序中输入了它,这让我进入了。万岁!但后来我突然意识到:这东西是如何工作的?它无法以某种方式连接到互联网,但它设法生成我的银行服务器接受的正确代码。嗯...里面有SIM卡或者类似的东西吗?没办法!
意识到我的生活将永远不一样,我开始想知道我上面提到的应用程序(Authy 和朋友)?我内心的研究员被唤醒了,所以我将手机切换到飞行模式,令我大吃一惊的是,我意识到它们在离线状态下工作得非常好:它们不断生成被应用程序服务器接受的代码。有趣!
不确定你的看法,但我一直认为一次性代币流是理所当然的,并且从未真正认真考虑过它(特别是因为现在我的手机很少没有互联网,除非我正在做一些户外探险),所以这是我惊讶的根本原因。否则,从安全的角度来看,以这种方式工作是完全有意义的,因为生成过程纯粹是本地的,因此对于外部参与者来说是安全的。但它是如何运作的呢?
嗯,像 Google 或 ChatGPT 这样的现代技术可以让您轻松找到答案。但这个技术问题对我来说似乎很有趣,所以决定先尝试一下并自己解决。
让我们从我们拥有的开始:
服务器验证部分提示服务器必须能够生成与离线设备相同的代码来比较它们。嗯..这会很有帮助。
对我的新“玩具”的进一步观察带来了更多发现:
我能想到的唯一逻辑解释是这些代码有一定的生命周期。我想讲述一个我试图以“1-2-3-...-N”方式计算它的持续时间的故事,但这不会是真的:我从像这样的应用程序中得到了一个很大的提示Authy and Co,我在那里看到了 30 秒的 TTL。很好的发现,让我们将其添加到已知事实列表中。
让我们总结一下到目前为止我们的要求:
好吧,但主要问题仍然没有得到解答:离线应用程序如何生成与其他应用程序中的值相匹配的值?他们有什么共同点?
如果您喜欢《指环王》宇宙,您可能还记得比尔博如何与咕噜玩谜语游戏,并解决了这个问题:
这个万物吞噬之物:
鸟、兽、树、花;
啃铁咬钢;
将坚硬的石头磨成粉;
杀死国王,摧毁城镇,
并击败高山。
剧透警告,但巴金斯先生很幸运,意外地得出了正确答案 - “时间!”。不管你相信与否,这也正是我们谜题的答案:任何 2 个(或更多)应用程序都可以访问同一时间,只要它们内部有嵌入式时钟。如今,后者已不再是问题,而且所讨论的设备足够大,可以容纳它。环顾四周,你的手表、手机、电视、烤箱和墙上的时钟上的时间很可能是相同的。我们在这里很感兴趣,似乎我们已经找到了 OTP(一次性密码)计算的基础!
依赖时间有其自身的一系列挑战:
让我们一一解决:
好的,这件事已经解决了,所以让我们尝试以时间为基础来实现我们算法的第一个版本。由于我们对 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”的逻辑没有意义,让我用一个简单的例子来解释一下:
我希望它现在更有意义。
但是,还没有满足所有要求,因为我们需要 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 秒内具有相同的值,但是一旦窗口过去,下一个值将是前一个值的增量。然后 base % 1_000_000 的行为就像我们看到的那样。我们之前的实现(使用私钥作为整数)也有同样的漏洞,但我们没有注意到这一点 - 缺乏测试。
我们需要将 current / 30 转换成某种东西,使其值的变化更加明显。
有多种方法可以实现这一点,并且存在一些很酷的数学技巧,但出于教育目的,让我们优先考虑我们将采用的解决方案的可读性:让我们将 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:
这是伪代码实现,它将根据上述 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中文网其他相关文章!