一种高容忍TOTP机制的设计和实现

706 阅读8分钟

本文的重点,并不是讨论OTP技术和其实现,而是在简单的阐述其概念和笔者对其理解之后,针对它在应用过程中可能出现的一个小问题,提出的改进方案。

OTP概述

OTP(One-Time Password, 一次性密码),是一种安全机制,用于提供临时有效的密码或认证代码,通常在用户进行身份验证或访问敏感信息时使用。根据OTP的实现机制,有TOTP(基于时间的OTP)、HTOP(基于Hash的OTP)等变种,我们这里主要讨论TOTP。

OTP的主要特点是每个密码只能使用一次,一般而言都有一定的有效时间。这种机制可以提供额外的安全性,因为即使密码被截获,攻击者也无法再次使用它进行身份验证。一次性密码的有效期可以是固定的(例如,30秒或1分钟),或者根据特定的算法和时间同步服务器进行动态生成。

OTP常见的使用场景包括:

  • 手机程序:可以使用专门的身份验证应用程序(如Google Authenticator、Authy等)生成和显示动态的一次性密码
  • 短信和邮件:一次性密码可以通过短信或者邮件发送给用户的注册手机号码或者邮箱
  • 硬件令牌:物理设备,如硬件令牌或USB密钥,可以生成和显示一次性密码
  • 基于时间同步的算法:使用事先共享的密钥和时间信息,客户端设备和程序可以生成与服务器端相匹配的一次性密码

但通常OTP不会被单独使用,而是用于增强身份验证的安全性,特别是在两步验证(2FA)或多因素身份验证中。用户在登录或执行敏感操作时,除了输入常规密码之外,还需要提供当前有效的一次性密码才能成功通过验证。这提供了一层额外的保护,防止恶意用户或黑客通过仅仅知道用户密码就能够入侵账户的情况。

原理和实现

基于时间的OTP的实现是非常简单的,根据相同的概念,其实可以有很多具体的实现方式。但这方面其实也有相关的标准,比如RFC6238(应该是被Google Authcator所使用)。它的基本定义如下:

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
// for TOTP
C = Round((Current Unix Time - T0) / X), // X=30

它的基础是使用HMAC-SHA-1算法。K是密钥,应该在使用OTP的各方共享,C是计数器,是一个可变参数;Truncate函数可以将摘要信息转换为一个简单的字符串(提供易用性,比如6个数字)。如果C,是由时间因素生成的,默认的时间步进是30秒钟,就可以由当前的时间,计算出当前时间的计数器,这个算法就变成了TOPT。

算法改进

由TOTP的原理可以看出,这个算法有两个小问题。我们就以GoogleAuthcator的使用为例,如果客户端和服务器端的时间不严格同步的话,可能会认证失败;另一个就是如果用户操作比较慢,输入验证码提交时,可能当前的验证码已经变了(所以程序里面有提示验证码有效的倒计时)。这两个问题可能不是太严重的,但会影响到应用体验。

为此,笔者这里提出一个改进的解决方案,可以改善上述问题。原理也比较简单,由于我们直到,基于时间的计数是有严格顺序的,因此,可以在验证和对比验证码的时候,不仅检查当前的验证码,也同时检查前一个和后一个验证码,这样基本上就可以弥补客户端、服务器和操作过程时间同步的差异了。

下面是这个构想的实现和测试代码:

const 
crypto  = require("crypto");
SYS_KEY = "AppKey";

const TOTP = {
    MARGIN: 15000, // 15 seconds
    code : (key, offset=0)=> crypto.createHmac("SHA256",Buffer.from(key))
            .update(((0 | Date.now() / TOTP.MARGIN) + offset) + SYS_KEY)
            .digest()
            .reduce((c,v,i)=>(c[i%2] ^= v,c),[0,0])
            .map(v=>v.toString().padStart(3,"0"))
            .join(""),
    check: (key,chash)=>[-1,0,1]
        .map(v=>TOTP.code(key,v))
        .map(v=>crypto.createHash("SHA256").update(v).digest("hex"))
        .includes(chash),
}


let SHARE_SECRET = 'TopSecret2023';


// client code and check 
let 
ccode = TOTP.code(SHARE_SECRET);
hcode = crypto.createHash("SHA256").update(ccode).digest("hex");

let timeout = 0|Math.random()*90000;

// check in 100s of future
console.log("Check Code",ccode, " in...", timeout);
setTimeout(_=>{
    console.log("Result:", TOTP.check(SHARE_SECRET,hcode));
},timeout);

这个程序实现有几个要点:

  • 程序基于js语言和crypto模块,其实任意支持hash和mac的算法都可以
  • 我们使用15作为步进时间,因为这个机制有了一定的裕度可以在安全性和可用性之间做一个平衡
  • 除了共享密钥之外,我们还可以使用一个应用密钥,来用同一个程序支撑多种应用系统
  • 检查编码时,检查的是编码的摘要值,可以提高安全性
  • TOTP.code,获取计算当前编码,使用某个密钥
  • TOTP.check, 使用密钥检查编码(其实是在三个编码摘要中检查)

扩展的使用场景

除了常见的使用场景之外,我们还可以构想一些扩展的使用场景和方式。

  • 跨应用认证

我们可以使用OTP作为简单的跨应用认证和集成的方案。应用之间可以没有接口或者直接的通信方式,只需设置共享密钥和检查机制即可。

  • 设备认证

有一个比较有趣的使用方式。据说小米的指纹锁,它的临时口令,就是使用OTP实现的。笔者猜想的实现过程如下: 小米门锁在出厂时,会为每个门锁设置一个随机密钥;用户安装时,需要绑定门锁,就可以将这个密钥同步到手机APP上;用户可以在APP上查看当前的验证码;在门锁中的验证码由于使用和APP一样的密钥,它们的验证码也是一样的,输入验证成功后就可以开锁了。

这里不需要这个设备联网,只需要提供输出密钥的方式就可以了(可能是蓝牙?)。另外重置密钥和绑定操作都可以比较简单,提供更好的安全性(防止密钥泄露后滥用)。 根据这个思路,很多需要验证的设备,都可以使用这个模式来改造。

  • 临时Token

我们经常会遇到一些需要分享网络链接,或者API接口的问题。这里的认证是一个比较麻烦的事情,比如需要在两个应用之间进行验证的集成,在链接中使用密码等等,都有一些不便和安全方面的问题。其实可以用基于OTP的临时Token来比较简单的解决。比如在简单的配置好共享密钥之后,客户端可以使用带有OTP验证码的链接地址来访问服务端,服务端可以对这个临时验证码进行验证来决定是否提供服务。这个验证方式,在服务端可以随时修改和取消,可以针对不同的客户端进行配置,链接或者地址也是有一定时效的,配置和使用也非常简单,是在这种场景和需求下比较好的解决方案。

  • 增强的安全性

如果觉得,默认的6位数字不安全,其实也可以非常简单的改造成更复杂的验证码格式。比如8位数字,或者有字母等等,来提升安全性(可能牺牲一些易用性)。

HOTP

增加一些HTOP的内容和思考。HTOP就是基于计数器的TOP,本质上而言TOTP是一个HOTP的特例,它使用时间作为步进和协商机制。就是省略了双方的步进状态管理的工作。所以,完整的HTOP的工作流程大概是这样的:

  • 双方初始状态后,计数器值都为0
  • 当使用OTP时,比如登录时,客户端可以使用当前计数器+1,计算OTP
  • 服务端同样将本地计数器+1,并计算OTP,进行匹配,决定是否验证成功
  • 如果验证成功,服务端将本地计数器+1,并保存
  • 服务端响应验证成功的信息,客户端收到后,将本地计数器+1

严格的工作流程应该就是这样,可以看到,这样的过程,需要双方进行状态的交互,所以使用场景是有一定的限制的。此外,可以考虑一下改进方案:

  • 如果考虑提高安全性,可以考虑一个计算值作为初始值,如从共享密钥计算而来
  • 步进的方式,也不一定为1,比如可以使用当前值的3的余数作为步进
  • 同样也可以计算多个步进值,可以容忍一定范围内计数不同步的状态
  • 如果遇到不严格同步的状态,服务端应当用某种方式通知到客户端

经过上述的改进,应该可以在安全性和可用性方面,达到一个比较好的平衡。