一次性密码算法 One-Time Password (OTP)

很多在线服务出于用户帐户安全的考虑,通常要求启用两步验证,即除了密码外,还需要一个验证码(通过短信、邮件发送,或者手机系统提示,或者验证器离线生成)。

对于短信、邮件等方式,服务端先发出验证码,再检查用户的输入是否有效,这个很好理解。(至于服务端生成验证码的算法,那是另一回事了)

基于时间的一次性密码算法

这里看一下验证器这种离线服务,它使用的是基于时间的一次性密码算法(Time-based One-Time Password,TOTP)。TOTP 是一种哈希算法,以初始密钥、当前时间戳为输入,经过 OTP 计算,输出约定个数的数字,即验证码。算法的具体输入包括:

  1. 初始密钥字符串
  2. 验证码位数,通常是 6 位数字,可以是其他长度
  3. 哈希算法,通常是 sha1,可以选择其他算法
  4. 时间间隔,通常是 30 秒,可以选择其他
  5. 当前时间戳,准确地说是当前是第几个时间间隔

所以,只要服务端和验证器客户端基于相同的初始密钥和其他配置,并且验证器所在客户端的时间是准的(或者说是时间差异在合理范围内),那么计算出来的数字就是一样的,这就实现了验证的目的。

这样验证的好处是,只要保护好初始密钥以及约定的算法参数,那么这个验证码是很难攻破的,并且因为每个验证码的时效很短,被看到也不影响。

验证器 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()

Read More: