一次性密码算法 One-Time Password (OTP)
很多在线服务出于用户帐户安全的考虑,通常要求启用两步验证,即除了密码外,还需要一个验证码(通过短信、邮件发送,或者手机系统提示,或者验证器离线生成)。
对于短信、邮件等方式,服务端先发出验证码,再检查用户的输入是否有效,这个很好理解。(至于服务端生成验证码的算法,那是另一回事了)
基于时间的一次性密码算法
这里看一下验证器这种离线服务,它使用的是基于时间的一次性密码算法(Time-based One-Time Password,TOTP)。TOTP 是一种哈希算法,以初始密钥、当前时间戳为输入,经过 OTP 计算,输出约定个数的数字,即验证码。算法的具体输入包括:
- 初始密钥字符串
- 验证码位数,通常是 6 位数字,可以是其他长度
- 哈希算法,通常是 sha1,可以选择其他算法
- 时间间隔,通常是 30 秒,可以选择其他
- 当前时间戳,准确地说是当前是第几个时间间隔
所以,只要服务端和验证器客户端基于相同的初始密钥和其他配置,并且验证器所在客户端的时间是准的(或者说是时间差异在合理范围内),那么计算出来的数字就是一样的,这就实现了验证的目的。
这样验证的好处是,只要保护好初始密钥以及约定的算法参数,那么这个验证码是很难攻破的,并且因为每个验证码的时效很短,被看到也不影响。
验证器 APP 可以选择微软或者谷歌的实现。
Python 实现
找到一个开源项目 pyotp: Python One-Time Password Library,使用时大概是这样的:
>>> import pyotp
>>> totp = pyotp.TOTP('ABCDEFGHIJKLMNOP')
>>> totp.now()
123456
如果不打算安装,可以从中抽取基本代码,去除第三方库的依赖,只保留基于 Python 标准库的实现,代码附在后面。
分析一下代码实现:
timecode(): 获取当前时间戳,除以时间间隔,得到当前是第几个时间间隔
generate_otp():
- 进行哈希计算,输入包括:
- 密钥:补齐到长度为 8 的倍数,再进行 base32 解码成 bytes,作为 key
- 时间戳:转换成 64 位、以大端格式的 bytes,作为 msg
- 将哈希结果变换成一个 32 位数
- 按十进制取这个数的低若干位数字(高位不足时补 0)
now(): 包装 generate_otp(timecode(...)),得到当前有效的验证码
at(): 可以得到指定时间间隔的验证码
#!/usr/bin/env python3
import base64
import datetime
import hashlib
import hmac
import time
from typing import Any, Optional, Union
class OTP:
def __init__(
self,
s: str,
digits: int = 6,
digest: Any = hashlib.sha1,
name: Optional[str] = None,
issuer: Optional[str] = None,
) -> None:
self.digits = digits
self.digest = digest
self.secret = s
self.name = name or 'Secret'
self.issuer = issuer
def generate_otp(self, input: int) -> str:
if input < 0:
raise ValueError('input must be positive integer')
hasher = hmac.new(self.byte_secret(), self.int_to_bytestring(input), self.digest)
hmac_hash = bytearray(hasher.digest())
offset = hmac_hash[-1] & 0xF
code = (hmac_hash[offset] & 0x7F) << 24 \
| (hmac_hash[offset + 1] & 0xFF) << 16 \
| (hmac_hash[offset + 2] & 0xFF) << 8 \
| (hmac_hash[offset + 3] & 0xFF)
str_code = str(code % 10**self.digits)
while len(str_code) < self.digits:
str_code = '0' + str_code
return str_code
def byte_secret(self) -> bytes:
secret = self.secret
missing_padding = len(secret) % 8
if missing_padding != 0:
secret += '=' * (8 - missing_padding)
return base64.b32decode(secret, casefold=True)
@staticmethod
def int_to_bytestring(i: int, padding: int = 8) -> bytes:
return i.to_bytes(padding, 'big')
class TOTP(OTP):
def __init__(
self,
s: str,
digits: int = 6,
digest: Any = hashlib.sha1,
name: Optional[str] = None,
issuer: Optional[str] = None,
interval: int = 30,
) -> None:
self.interval = interval
super().__init__(s=s, digits=digits, digest=digest, name=name, issuer=issuer)
def at(self, for_time: Union[int, datetime.datetime], counter_offset: int = 0) -> str:
if not isinstance(for_time, datetime.datetime):
for_time = datetime.datetime.fromtimestamp(int(for_time))
return self.generate_otp(self.timecode(for_time) + counter_offset)
def now(self) -> str:
return self.generate_otp(self.timecode(datetime.datetime.now()))
def timecode(self, for_time: datetime.datetime) -> int:
return int(time.mktime(for_time.timetuple()) / self.interval)
def main():
otp = TOTP('ABCDEFGHIJKLMNOP')
print(otp.now())
if __name__ == '__main__':
main()