什么是基于时间的一次性密码 TOTP
随着网络安全意识的提升,一次性密码(One-Time Password, OTP)被广泛用于多因素认证中,以增强账户的安全性。时间基一次性密码(Time-Based One-Time Password, TOTP)算法是实现OTP的一种流行方式,它根据当前时间来生成密码。
基于时间的一次性密码 TOTP(Time-Based One-Time Password),也被称为时间同步动态密码,通常用于两步验证和多因素身份验证,用于增强静态口令认证的安全性。
TOTP 算法由互联网工程任务组 (IETF) 在 RFC 6238 中定义,是基于 HMAC (基于哈希的消息认证码) 的一次性密码算法 (HOTP) 的扩展,添加了一个时间因素。
TOTP的工作原理是将时间作为密码生成的关键因素,使用 HMAC-SHA-1 算法,将当前时间作为输入,并使用一个共享密钥(K)和一个时间参数(T)作为参数进行计算。其中,共享密钥是事先在客户端和服务器之间协商好的。
TOTP 工作流程
- 初始化:用户在服务提供商(如 Google、Facebook 等)注册账户时,服务提供商(服务器)会为用户生成一个密钥(Secret Key)并保存在数据中。然后把这个密钥会以某种方式(通常是二维码)分享给用户,用户将其添加到(通过扫码)自己的身份验证器应用(如 Google Authenticator、Microsoft Authenticator)中。
- 生成 TOTP:身份验证器应用会按照固定的时间间隔(常见的是 30 秒)使用 HMAC-SHA1 算法,使用当前的时间戳和在初始化步骤中获取的密钥,生成一个新的一次性密码,这个密码通常是一个 6 位数字(但也可能更长或更短)。
- 验证 TOTP:当用户尝试登录或执行需要验证的操作时,会被要求提供当前的一次性密码。用户从自己的身份验证器应用中获取这个密码,并输入到服务提供商的网站或应用中。服务提供商会使用同样的算法和密钥,以及当前的时间戳,生成一个一次性密码,并将其与用户提供的密码进行比较。如果两个密码匹配,那么用户的身份就被认为已经验证。
TOTP 计算原理
- 共享密钥:服务端和客户端共享一个密钥,通常以Base32编码表示。
- 时间同步:TOTP算法要求用户设备和服务器之间时间同步,以确保生成的密码一致。
- 时间窗口:时间被分为30/60秒为一个单位的窗口
- 时间步长:当前时间步长 = 当前Unix时间戳 / 30秒
- HMAC计算:使用共享密钥和当前时间步长通过HMAC算法生成哈希值,常用的算法为SHA-1、SHA-256和SHA-512。
- 动态码生成:从哈希值中提取6/8位数字(通常6位)
一些在线TOTP:
- https://iotools.cloud/zh/tool/otp-code-generator/ (有详细的解释)
- https://www.btool.cn/otp-generator (可以修改HMAC算法)
- https://otp.tbbbk.com/ (最简洁)
通用的TOTP二维码数据:otpauth://totp/<应用名称>:<用户名>?issuer=<应用名称>&secret=XBTYBHI7YHGC354G&algorithm=SHA1&digits=6&period=30
Python实现TOTP
原生实现
#!/usr/bin/env python
# -*- coding:utf-8 -*-
# auth: wuye
import hmac
import hashlib
import time
import struct
import base64
def base32_decode(encoded):
"""Base32解码,确保密钥长度是8的倍数(添加填充)"""
encoded = encoded.upper()
padding = 8 - (len(encoded) % 8)
if padding != 8:
encoded += '=' * padding
try:
return base64.b32decode(encoded)
except Exception as e:
print(f"Base32解码错误: {e}")
return None
def generate_totp(secret, timestamp=None, interval=30, digits=6) -> str:
"""
生成基于时间的一次性密码(TOTP)
参数:
secret: 共享密钥(未经过Base32编码)
timestamp: 时间戳(如果为None,则使用当前时间)
interval: 时间间隔(秒)
digits: TOTP代码的位数
返回值:
str: 生成的TOTP代码
"""
# 使用Base32解码密钥
key = base32_decode(secret)
# print(f"十六进制key: {key.hex()}")
# 计算时间步长
if timestamp is None:
timestamp = int(time.time())
timestep = int(timestamp // interval)
# 将时间步长转换为8字节大端字节串
# 格式字符串">Q"中,>表示大端序(网络字节序),Q:无符号长整数(8字节),I:无符号整数(4字节)。
# 之所以使用8字节(64位),是因为TOTP标准规定时间步长应为64位整数。这足够覆盖当前Unix时间戳所有可预见的时间范围。
msg = struct.pack(">Q", timestep)
# 计算HMAC-SHA1
hmac_hash = hmac.new(key, msg, hashlib.sha1).digest()
# 动态截断
offset = hmac_hash[-1] & 0x0F
truncated_hash = hmac_hash[offset:offset + 4]
# 将截断的字节转换为整数
code_int = struct.unpack(">I", truncated_hash)[0] & 0x7FFFFFFF
# 计算TOTP值
totp_value = code_int % (10 ** digits)
# 添加前导零使长度固定
return str(totp_value).zfill(digits)
def verify_totp(secret, code, interval=30, digits=6) -> bool:
"""
验证TOTP代码
参数:
secret: 共享密钥(未经过Base32编码)
code: 要验证的TOTP代码
digits: TOTP代码的位数
返回值:
bool: 如果代码有效则返回True,否则返回False
"""
# 考虑到时间漂移,检查前后一个时间窗口(总共3个窗口)
timestamp = int(time.time())
# 当前时间窗口
if generate_totp(secret, timestamp, digits=digits) == code:
return True
# 前一个时间窗口(x秒之前)
if generate_totp(secret, timestamp - interval, digits=digits) == code:
return True
# 后一个时间窗口(x秒之后)
if generate_totp(secret, timestamp + interval, digits=digits) == code:
return True
return False
# Example usage
if __name__ == "__main__":
secret = "IU655SJR7ISY"
ret = generate_totp(secret)
print(f"生成的TOTP为:{ret}")
动态截断是TOTP(以及HOTP)算法中的一个核心步骤,其目的是从20字节的HMAC-SHA1哈希结果中提取出4字节的动态二进制码,然后将其转换为一个整数,最后通过取模运算得到指定长度的TOTP值。
详细步骤解释:
- 动态截断的目的:
HMAC-SHA1生成的哈希值是20字节(160位),但我们需要一个较短的、固定长度的值(比如4字节)来生成OTP。动态截断的方法并不是简单地从哈希值的开头或结尾取4字节,而是根据哈希值的最后一个字节的低4位(即一个0-15的值)作为偏移量,然后从该偏移量开始取4字节。这种方法增加了不确定性,因为偏移量取决于哈希值本身,使得攻击者更难预测。
- 步骤分解:
- 计算偏移量:
offset = hmac_hash[-1] & 0x0F
hmac_hash[-1] 是哈希值的最后一个字节,与0x0F进行按位与操作,相当于取最后一个字节的低4位,得到一个0~15之间的数,作为偏移量。- 截取4字节:
truncated_hash = hmac_hash[offset:offset+4]
从偏移量开始,连续取4个字节。这样我们就得到了一个4字节的二进制字符串。
- 转换为整数:
- 使用
struct.unpack(">I", truncated_hash)将4字节的二进制字符串转换成一个无符号整数(大端序)。注意,这个整数可能是很大的(0~2^32-1)。- 然后,我们使用
& 0x7FFFFFFF操作来确保这个整数是正数(清除最高位,即符号位)。因为TOTP需要的是一个正数。
- 取模得到指定位数的TOTP值:
totp_value = code_int % (10 ** digits)
这个操作将整数映射到0到(10^digits - 1)的范围内。例如,如果digits=6,那么模数就是1000000,得到0~999999之间的数。
为什么不直接取哈希值的后几位?
如果直接取哈希值的后几位,那么每次计算得到的4字节都是固定的位置,这样会降低算法的安全性。动态截断通过哈希值的最后一个字节的动态值来决定截取位置,使得截取的位置随着哈希值的不同而变化,增加了攻击的难度。为什么取模后不直接转换为字符串并取后几位?
实际上,取模后的结果已经是一个0到(10^digits-1)之间的整数,我们将其格式化为digits位的字符串(不足位前面补0)。这个数字是均匀分布的(因为模运算),并且我们取的是整个整数,不是它的后几位。如果我们取后几位,那么当数字的位数超过digits时,我们只能得到后面的digits位,而前面的位就丢失了。而取模运算可以保证我们得到的是整个整数范围的一个均匀抽样,然后我们将其格式化为固定位数。注意:在HOTP/TOTP的标准中,动态截断(Dynamic Truncation)是必须的,它被定义为从HMAC结果中提取一个31位的动态二进制码(即4字节,但最高位被屏蔽,所以是31位)。然后取模运算将其映射到指定位数的数字。
示例:
假设我们有一个HMAC哈希值(以十六进制表示):
hmac_hash = “1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a”
最后一个字节是0x5a,那么偏移量 = 0x5a & 0x0F = 0x0A(即10)
然后我们从第10个字节(从0开始计数)开始取4个字节:50 ef 7f 19
将其转换为大端序整数:0x50ef7f19 = 1357872921
然后清除最高位(实际上这里最高位已经是0,因为0x50的最高位是0,所以不变)得到1357872921
然后对10^6取模:1357872921 % 1000000 = 872921
所以TOTP就是0872921(如果要求6位,则前面补0,得到872921)
使用pyotp库的简化版本
需要先安装 pyotp 三方库:pip install pyotp 。
import pyotp
import time
def generate_totp2(secret=None):
# 生成共享密钥
if secret is None:
secret = pyotp.random_base32()
print(f"共享密钥: {secret}")
# 创建TOTP对象
totp = pyotp.TOTP(secret, interval=30, digits=6)
# 生成当前验证码
current_code = totp.now()
print(f"当前TOTP验证码: {current_code}")
# 验证验证码
print(f"验证结果: {totp.verify(current_code)}")
# 生成Provisioning URI (用于生成二维码)
uri = totp.provisioning_uri("user@example.com", issuer_name="MyApp")
print(f"\nProvisioning URI:\n{uri}")
if __name__ == "__main__":
secret = "IU655SJR7ISY"
generate_totp2(secret)
总结
TOTP 的优点在于利用了时间作为动态因素,使得密码具有一次性的特点,可以防止重放攻击。即使攻击者能够截获一个一次性密码,也无法再次使用它,因为密码在短时间后就会过期。此外,由于密码是基于时间和密钥生成的,因此攻击者无法预测未来的密码,除非能够获取到密钥。
TOTP 除了以上的优点,也是有一定局限性的。TOTP 算法对于时间的同步要求较高,需要客户端和服务器之间的时间保持精确同步。