很多网站的登录系统都支持二步验证(双因素验证,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 没有硬件密码学加速,我们需要用软件实现。好在计算量很小,效率不成问题。

  结果符合预期:

0:00
/

  源码:

#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);
}