算法 动态密码生成算法介绍与实现

martin91 · 发布于 2017年02月18日 · 最后由 martin91 回复于 2017年03月30日 · 4758 次阅读
4755
本帖已被设为精华帖!

动态密码,亦称一次性密码(One Time Password, 简称 OTP),是一种高效简单又比较安全的密码生成算法,在我们的生活以及工作中随处可见,身为开发者,也或多或少在自己的业务系统中集成了二步验证机制,那么,技术运用,既要知其然,更要知其所以然,动态密码算法是怎样的?

内容

读前指引

  • 通过这篇文章,你可以了解以下知识:
    • 动态密码的背景知识
    • 动态密码的分类
    • 不同动态密码的生成算法,HOTP 以及 TOTP
    • HOTP 以及 TOTP 的简单的 Ruby 编程语言的实现
    • 两类算法各自注意事项
  • 限于篇幅,我不会讨论以下几点,有兴趣的同学可以参考我文章末尾给出的参考资料了解:
    • 不同动态密码的安全性分析
    • 计时动态密码如何确保有效期间内,密码不被二次使用

动态密码背景介绍

从我的角度理解,动态密码是指随着某一事件(密码被使用、一定的时间流逝等)的发生而重新生成的密码,因为动态密码本身最大优点是防重复执行攻击(replay attack),它能很好地避免类似静态密码可能被暴力破解等的缺陷,现实运用中,一般采用“静态密码+动态密码”相结合的双因素认证,我们也称二步验证。

而动态密码其实很早就出现在我们的生活里了,在移动支付发展起来之前,网银是当时最为流行的在线支付渠道,当时银行为了确保大家的网银账号支付安全,都会给网银客户配发动态密码卡,比如中国银行电子口令卡(按时间差定时生成新密码,口令卡自带电池,可保证连续使用几年),或者工商银行的电子银行口令卡(网银支付网页每次生成不同的行列序号,用户根据指定行列组合刮开密码卡上的涂层获取密码,密码使用后失效),又或者银行强制要求的短信验证码,这些都可以纳入动态密码的范畴。 中行电子口令卡 工行电子银行口令卡

而随着移动互联网的发展以及移动设备的智能化的不断提高,设备间的同步能力大幅提升,以前依赖独立设备的动态密码生成技术很快演变成了手机上的动态密码生成软件,以手机软件的形式生成动态密码的方式极大提高了动态密码的便携性,一个用户一个手机就可以管理任意多个动态密码的生成,这也使得在网站上推动二步验证减少了很多阻力,因为以往客户可能因为使用口令卡太麻烦,而拒绝打开二步验证机制,从而让自己的账号暴露在风险之下。最为知名的动态密码生成软件,当属 Google 的 Authenticator APP。
Google Authenticator

动态密码算法探索之旅

动态密码的分类

一般来说,常见的动态密码有两类:

  • 计次使用:计次使用的OTP产出后,可在不限时间内使用,知道下次成功使用后,计数器加 1,生成新的密码。用于实现计次使用动态密码的算法叫 HOTP,接下来会对这个算法展开介绍,实际例子就是上面工行的电子银行口令卡;
  • 计时使用:计时使用的OTP则可设定密码有效时间,从30秒到两分钟不等,而OTP在进行认证之后即废弃不用,下次认证必须使用新的密码。用于实现计时使用动态密码的算法叫 TOTP,接下来会对这个算法展开介绍,实际例子就是上面中行的电子口令卡。

在真正开展算法介绍之前,需要补充介绍的是:动态密码的基本认证原理是在认证双方共享密钥,也称种子密钥,并使用的同一个种子密钥对某一个事件计数、或时间值进行密码算法计算,使用的算法有对称算法、HASH、HMAC等。记住这一点,这个是所有动态密码算法实现的基础。

HOTP

HOTP 算法,全称是“An HMAC-Based One-Time Password Algorithm”,是一种基于事件计数的一次性密码生成算法,详细的算法介绍可以查看 RFC 4226。其实算法本身非常简单,算法本身可以用两条简短的表达式描述:

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
PWD(K,C,digit) = HOTP(K,C) mod 10Digit

上式中:

  • K 代表我们在认证服务器端以及密码生成端(客户设备)之间共享的密钥,在 RFC 4226 中,作者要求共享密钥最小长度是 128 位,而作者本身推荐使用 160 位长度的密钥
  • C 表示事件计数的值,8 字节的整数,称为移动因子(moving factor),需要注意的是,这里的 C 的整数值需要用二进制的字符串表达,比如某个事件计数为 3,则C是 "11"(此处省略了前面的二进制的数字0)
  • HMAC-SHA-1 表示对共享密钥以及移动因子进行 HMAC 的 SHA1 算法加密,得到 160 位长度(20字节)的加密结果
  • Truncate 即截断函数,后面会详述
  • digit 指定动态密码长度,比如我们常见的都是 6 位长度的动态密码

Truncate 截断函数

由于 HMAC SHA-1 算法是既有算法,不是我们讨论重点,故而 Truncate 函数就是整个算法中最为关键的部分了。以下引用 Truncate 函数的步骤说明:

DT(String) // String = String[0]...String[19]
Let OffsetBits be the low-order 4 bits of String[19]
Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15
Let P = String[OffSet]...String[OffSet+3]
Return the Last 31 bits of P

结合上面的公式理解,大概的描述就是:

  1. 先从第一步通过 SHA-1 算法加密得到的 20 字节长度的结果中选取最后一个字节的低字节位的 4 位(注意:动态密码算法中采用的大端(big-endian)存储);
  2. 将这 4 位的二进制值转换为无标点数的整数值,得到 0 到 15(包含 0 和 15)之间的一个数,这个数字作为 20 个字节中从 0 开始的偏移量;
  3. 接着从指定偏移位开始,连续截取 4 个字节(32 位),最后返回 32 位中的后面 31 位。

回到算法本身,在获得 31 位的截断结果之后,我们将其又转换为无标点的大端表示的整数值,这个值的取值范围是 0 ~ 231,也即 0 ~ 2.147483648E9,最后我们将这个数对10的乘方(digit 指数范围 1-10)取模,得到一个余值,对其前面补0得到指定位数的字符串。

代码示例

以下代码示例也可访问 Gist 获取。

require 'openssl'

def hotp(secret, counter, digits = 6)
  hash = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), secret, int_to_bytestring(counter))  # SHA-1 算法加密
  "%0#{digits}i" % (truncate(hash) % 10**digits)  # 取模获取指定长度数字密码
end

def truncate(string)
  offset = string.bytes.last & 0xf           # 取最后一个字节
  partial = string.bytes[offset..offset+3]   # 从偏移量开始,连续取 4 个字节
  partial.pack("C*").unpack("N").first & 0x7fffffff    # 取后面 31 位结果后得到整数
end

def int_to_bytestring(int, padding = 8)
  result = []
  until int == 0
    result << (int & 0xFF).chr
    int >>= 8
  end
  result.reverse.join.rjust(padding, 0.chr)
end

上面的算法实现代码量很少,核心都是按照算法描述进行多个掩码运算跟位操作而已。

密码失效机制

从上面的分析可以看到,一个动态密码的生成,取决于共享密钥以及移动因子的值,而共享密钥是保持不变的,最终就只有移动因子决定了密码的生成结果。所以在 HOTP 算法中,要求每次密码验证成功后,认证服务器端以及密码生成器(客户端)都要将计数器的值加1,已确保得到新的密码。

但是在这里就会引入一个问题,假如认证服务器端与密码生成器之间由于通信故障或者其他意外情况,导致两边计数器的值不同步了,那么就会导致两边生成的密码无法正确匹配。为了解决这个问题,算法在分析中建议认证服务器端在验证密码失败后,可以主动尝试计数器减1之后重新生成的新密码是否与客户端提交密码一致,如果是,则可以认定是客户端计数器未同步导致,这种情况下可以通过验证,并且要求客户端重新同步计数器的值。

出了上面提到的计数器不同步的问题,我另外想的是,如果客户有多个密码生成器(假设 iPad 和 iPhone)为同个账号生成密码,那么计数器在多个设备间的同步可能就需要另外考虑的方案了。

小结

其实 HOTP 的算法比我在阅读算法前所想象的要简洁得多,而且仍然足够强健。算法本身巧妙利用了加密算法对共享密钥和计数器进行加密,确保这两个动态密码生成因子不被篡改,接着通过一个 truncate 函数随机得到一个最长 10 位的 10 进制整数,最终实现对 1 - 10 位长度动态密码的支持。算法本身的简洁也确保了算法本身可以在各种设备上实现。

TOTP

TOTP 算法,全称是 TOTP: Time-Based One-Time Password Algorithm,其基于 HOTP 算法实现,核心是将移动因子从 HOTP 中的事件计数改为时间差。完整的 TOTP 算法的说明可以查看 RFC 6238,其公式描述也非常简单:

TOTP = HOTP(K, T) // T is an integer and represents the number of time steps between the initial counter time T0 and the current Unix time

More specifically, T = (Current Unix time - T0) / X, where the default floor function is used in the computation.

通常来说,TOTP 中所使用的时间差都是当前时间戳,TOTP 将时间差除以时间窗口(密码有效期,默认 30 秒)得到时间窗口计数,以此作为动态密码算法的移动因子,这样基于 HOTP 算法就能方便得到基于时间的动态密码了。
注意: RFC 6238 提到,在 TOTP 算法中,可以指定不同的键控哈希算法,比如在 HOTP 中使用的是 HMAC-SHA1 算法,而在 TOTP,除此之外,还可以使用 HMAC-SHA256 或者 HMAC-SHA512。

TOTP implementations MAY use HMAC-SHA-256 or HMAC-SHA-512 functions, based on SHA-256 or SHA-512 [SHA2] hash functions, instead of the HMAC-SHA-1 function that has been specified for the HOTP computation in [RFC4226].

代码示例

以下代码示例也可访问 Gist 获取。

require 'hotp'

# 仅为示例方便,复用了前述代码,如果需要使用 HMAC-SHA256 或者 HMAC-SHA512,需要另外实现
def totp(secret, digits = 6, step = 30, initial_time = 0)
  steps = (Time.now.to_i - initial_time) / step

  hotp(secret, steps, digits)
end

看到了吧,极其简短的实现代码!一个时间计数的动态密码算法就此诞生,如此简单的算法,却是支撑多少业务系统安全运作的基石,颇有四两拨千斤的快感!

问题探讨

  1. ROTP 算法中的主要问题是计数器的同步,而 TOTP 也不例外,只是问题在于服务器端与客户端之间时间的同步,由于现在互联网的发达,加上移动设备一般都会按照网络时间设置设备时间,基本上时间的相对同步都不是问题;
  2. 时间同步的另一个问题其实是边界问题,假如客户端生成密码的时间刚好是第 29 秒,而由于网络延迟等原因,服务器受理验证时刚好是下一个时间窗口的第 1 秒,这个时候会导致密码验证失效。于是,TOTP 算法在其算法讨论中,也建议服务器在验证密码失败之后,可以尝试将自身的时间窗口值减 1 之后重新生成密码比对,如果验证通过,说明验证不通过是时间窗口的边界问题导致,这个时候可以认为密码验证通过。
  3. 基于时间的动态密码的另一个好处是避免了基于计数器的多设备间的计数器同步问题,因为每台设备以及服务端都可以自行与网络时间(共同时间标准)校准,而无需依赖服务端的时间。

Google Authenticator

在 Google Authenticator 的开源项目的 README 里有明确提到:

These implementations support the HMAC-Based One-time Password (HOTP) algorithm specified in RFC 4226 and the Time-based One-time Password (TOTP) algorithm specified in RFC 6238.

也就是说,至此,我们也明白了,其实 Google Authenticator 算法核心也是 HOTP 以及 TOTP ,在明白了整个动态密码算法的核心之后,有没有一种豁然开朗的感觉呢?既知其然,又知其所以然了,对吧?每次看着应用定时生成密码,

总结

这篇文章简单介绍了两类常见的动态密码的生成算法,算法本身简洁不复杂,效率并且足够强健。这篇文章的目的是方便跟我一样希望了解算法核心的小伙伴,而在 RFC 文档中,仍有大量关于算法本身的安全性方面的探讨,有兴趣的小伙伴可以去看一下。

参考资料

  1. Wikipedia: 一次性密码
  2. github: rotp,HOTP 以及 TOTP 的算法实现以及其他封装
  3. 动态口令(OTP)认证技术概览
  4. RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm
  5. RFC 6238 - TOTP: Time-Based One-Time Password Algorithm
  6. 详解Google Authenticator工作原理

示例源码

Gist: OTP algorithms in Ruby

共收到 16 条回复
1 Rei 将本帖设为了精华贴 02月18日 13:42
3

OTP 应用范围其实挺广泛的,特别是跟交易和支付有关的场景,如果是 1Password 的用户那么你们有福了,1Password 从 2015 年开始就支持 OTP,而且还支持云备份,我已经用了很久了 https://blog.agilebits.com/2015/01/26/totp-for-1password-users/

4755

#3楼 @lgn21st 太棒!一直抱怨 google authentication 不支持备份,原来 1 password支持

3

#4楼 @martin91 是啊,而且 1Password 的 iOS 版本支持指纹解锁,跟 macOS 桌面版本通过 iCloud 同步,比 Google Authenticator 方便多了,也安全省心多了。

6061

很有用,早前就想利用 OTP 做一个类似 Google Authenticator 的 app, 还可以加入到 ssh 登录做两步验证。

4755

#5楼 @lgn21st 我最痛苦的是,如果不小心(比如系统还原)需要重装 Google Authenticator,简直痛苦,所有动态密码都得重新配置。用 1Password 可以方便使用它的加密功能,就不用担心密钥泄露了。感谢分享这个好消息!

4755

#6楼 @gihnius ssh 二步验证这个我们都有集成,业界应该是有成熟的工具的。

2099

#7楼 @martin91 我上周已经用上了,哈哈哈

4755

#9楼 @hz_qiuyuanxin 嘚瑟 ╭(╯╰)╮

2099

#10楼 @martin91 其实是我左边的告诉我的,我左边的又是左边告诉他的,哈哈。

96

SHA-1 换为SHA-2 OpenSSL::Digest.new('sha256')

14154
3lgn21st 回复

这个功能竟然不知道 omg

9058

貌似用同样的密钥,跟Google Authenticator算出来不一样,怎么回事喃。。。

4755
9058raofeng 回复

记得还有个 base32 的过程,我后头看下

4755 martin91 只要六步,轻松添加 Google 两步认证 中提及了此贴 09月17日 21:00
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册