二步验证 TOTP 协议及其实现
很多网站的登录系统都支持二步验证(双因素验证,Two-factor authentication, 2FA)以增强安全性。所谓二步验证,即是在检查口令之外,还检查一个一次性认证码。典型的二步认证手段包括有:
- 短信/邮箱验证码
- 银行发的密码器
- 基于 HOTP/TOTP 协议的应用程序
我们主要讨论最流行的 TOTP 协议,这也是 Authy、Google Authenticator 等流行的二步认证应用所支持的协议。
0x01 工作流程
如果用户选择打开 2FA,网站会生成一个预共享的 secret key。后续的验证码都是从这个 secret key 派生出来的,这个 secret key 应该严格保密,只有服务器和客户应当知晓。
用户登录时,首先输入口令,这是第一步验证。口令验证通过之后,服务器要求用户提供 6 位数字认证码(这个认证码由 secret key 与当前时间计算得出,每 30s 变一次),若与服务器的计算结果一致,则完成认证。
一般而言,secret key 由服务器生成,并在用户选择开启 TOTP 时提供给客户。客户的手机上预装有 Authy 等软件,扫描服务器给出的二维码,即可获取到 secret key 并保存。
如此一来,即使客户的密码被盗取,只要攻击者不知道 secret key,则仍然无法登录上网站。由于 secret key 保存在手机 APP 上,一般是比较安全的(如果不是物理黑客的话)。
0x02 算法
在讲 TOTP 之前,我们先来看它的初级版——HOTP。HOTP 与 TOTP 的区别在于:HOTP 是用一个整数 counter 来计算一次性验证码,而 TOTP 是基于当前时间来计算。显然,实现了前者就能实现后者——只需要把当前时间除以 30s 向下取整,就能得到一个整数 counter 送进 HOTP 协议里。
HOTP 的全称是 HMAC-based One-Time Password,由 RFC 4226 定义。一次性验证码是 secret key $K$ 与计数器 $C$ 的函数:$$HOTP(K, C) = \text{truncate}(\text{HMAC}_H(K, C))$$而用户实际所使用的六位数字认证码,即为 $HOTP(K, C) \bmod 10^6$。上面的 HMAC 过程使用的哈希函数 $H$ 默认为 SHA-1。
truncate 过程用于从 160bit 的 SHA-1 结果中,提取出 31bit 的摘要。代码如下:
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)
)
可见 HOTP 是一个非常简单的协议。而 TOTP(Time-based One-Time Password),就是把 unix 时间戳(1970 年 1 月 1 日至今所经过的秒数),除以 30 向下取整,作为 counter 丢进 HOTP 进行计算。
0x03 物联网实现
现在,我们在 ESP8266 上实现 TOTP 协议,显示于 8 位数码管上。
首先要解决的是时间问题。显然,我们的开发板不像电脑主板一样拥有 CMOS 电池(即主板上那颗 CR2032 型号的纽扣电池,可以在电脑关机时也维护时间信息),上电的时候肯定不知道现在是什么时间的;因此我们需要使用联网对时服务。
接下来的问题是如何计算 HMAC。由于 ESP8266 没有硬件密码学加速,我们需要用软件实现。好在计算量很小,效率不成问题。
结果符合预期:
源码:
#include <Arduino.h>
#include <LedControl.h>
#include <Crypto.h>
#include <SHA1.h>
#include <utility/EndianUtil.h>
#include <Base32.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
#include <NTPClient.h>
const char *ssid = "******";
const char *password = "******";
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "ntp.aliyun.com");
LedControl lc=LedControl(D6, D0, D5, 1);
String originSecret = "ONSWG4TFOQYTEMZTGIYQ";
byte* secret;
size_t secretLength;
void printHex(byte *a, size_t len) {
for(size_t i=0; i<len; i++)
Serial.printf("%02x ", a[i]);
Serial.print("\n");
}
void calcByteSecret() {
byte tmp[30];
size_t originLength = originSecret.length();
for(size_t i=0; i<originLength; i++)
tmp[i] = originSecret[i];
Base32 base32;
secret = (byte *)malloc(30);
secretLength = base32.fromBase32(tmp, originLength, secret);
Serial.printf("Byte Secret: ");
printHex(secret, secretLength);
}
uint64_t currentCode;
void calcHotp(uint64_t count) {
byte hashCode[20];
SHA1 hasher;
hasher.resetHMAC(secret, secretLength);
byte counterData[8];
for(int i=0; i<8; i++)
counterData[7-i] = ((count >> (i*8)) & 0xFF);
Serial.printf("Counter data: ");
printHex(counterData, 8);
hasher.update(counterData, 8);
hasher.finalizeHMAC(secret, secretLength, hashCode, 20);
Serial.printf("Result: ");
printHex(hashCode, 20);
int offset = hashCode[19] & 0xF;
uint64_t digitalCode = (
(hashCode[offset] & 0x7F) << 24
| (hashCode[offset + 1] & 0xFF) << 16
| (hashCode[offset + 2] & 0xFF) << 8
| (hashCode[offset + 3] & 0xFF)
);
currentCode = digitalCode % 1000000;
Serial.printf("Code: %llu -> %llu\n", digitalCode, currentCode);
}
void flushLed() {
uint64_t now = currentCode;
for(int i=0; i<6; i++) {
lc.setChar(0, i, now % 10, 0);
now /= 10;
}
}
void setup() {
Serial.begin(9600);
Serial.print("\n\n\n\n\n[Starting]\n");
lc.shutdown(0,false);
lc.setIntensity(0,1);
lc.clearDisplay(0);
calcByteSecret();
WiFi.begin(ssid, password);
Serial.print("Connect to WiFi");
while ( WiFi.status() != WL_CONNECTED ) {
delay ( 500 );
Serial.print ( "." );
}
Serial.print("\nIP: ");
Serial.println(WiFi.localIP());
timeClient.begin();
Serial.print("\n\n\n\n\n[Start OK]\n");
}
void loop() {
timeClient.update();
uint64_t timestamp = timeClient.getEpochTime();
Serial.printf("timestamp: %llu, counter=%llu\n", timestamp, timestamp / 30);
calcHotp(timestamp / 30);
flushLed();
delay(200);
}