双因子认证-Google Authenticator工作原理

本文深入解析双因子认证(2FA)的原理,并以 Google Authenticator 为例,介绍基于时间的一次性密码(TOTP)是如何生成与验证的。适合开发者、产品设计师及对账号安全机制感兴趣的读者了解其背后的机制与实现逻辑。
single

双因子认证

Google Authenticator 是谷歌推出的一款动态口令工具,旨在解决大家 Google 账户遭到恶意攻击的问题,在手机端生成动态口令后,在 Google 相关的服务登陆中除了用正常用户名和密码外,需要输入一次动态口令才能验证成功,此举是为了保护用户的信息安全。

很多手机用户会使用 Google Authenticator(谷歌身份认证)来生成认证令牌,与传统单因子密码不同,其采用的是更安全的双因子(2 FA two-factor authentication)认证。FA 是指结合密码以及实物(信用卡、SMS 手机、令牌或指纹等生物标志)两种条件对用户进行认证的方法。只需要在手机上安装如此高大上的密码生成应用程序,就可以生成一个随着时间变化的一次性密码,用于帐户验证,而且这个应用程序不需要连接网络即可工作。

OTP

两步验证中使用的密码是一次性密码(One-Time Password 简称 OTP),也称为动态口令。是使用密码技术实现的在客户端和服务器之间通过共享密钥的一种强认证技术,是增强目前静态口令认证的一种非常方便技术手段,是一种重要的两步验证认证技术。 Wikipedia解释

HOTP (HMAC-Based One-Time Password Algorithm)

HOTP 是基于 HMAC 算法生成的一次性密码,也称事件同步的动态密码,是 ITEF RFC 4226 公开的算法规范, 伪代码如下: HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))

客户端和服务器事先协商好一个密钥 K,用于一次性密码的生成。客户端和服务器各有一个事件计数器 C,并且事先将计数值同步。Truncate 是将 HMAC-SHA-1 产生的 20 字节的字符串转换为若干位十进制数字的算法。

TOTP (Time-Based One-Time Password Algorithm)

TOTP 是 HOTP 的改良版,使用时间替换掉 HOTP 的事件计数器 C,也称时间同步的动态密码。详细规范见 RFC 6238,伪代码: TOTP = Truncate(HMAC-SHA-1(K,T))

T = (Current Unix time - T 0) / X

T 0 是初试时间,默认为 0

X 是时间步长,默认 30 秒

官方文档中举了个例子,假设当前 unix 时间=59,T 0=0,X=30,则 T=1

假设当前 unix 时间=60,T 0=0,X=30,则 T=2

也就是对 T 的值向下取整,抛弃小数的意思

实际上 Google Authenticator 采用的算法是 TOTP(Time-Based One-Time Password 基于时间的一次性密码),其核心内容包括以下三点:

  • 一个共享密钥(一个比特序列);
  • 当前时间输入;
  • 一个签署函数。

TOTP 算法

共享密钥

共享密码用于在手机端上建立账户。密码内容可以是通过手机拍照二维码或者手工输入,并会被进行 base 32 加密。手工密码的输入格式如下:

xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx

包含该令牌的二维码的内容是一个 URL:

otpauth://totp/Google: [[email protected]](mailto:[email protected])?secret=xxxx&issuer=Google

时间输入 (当前时间)

输入的时间值来自于手机本身,一旦我们获得密钥后,就无需与服务器再进行通信了。但是最重要一点是务必确保手机上的时间是正确的,因为往后的步骤服务器会多次重复使用之前得到的时间值,服务器只会认准这个值。进一步说,服务器会比对所有提交的令牌以确认哪一个是你输入并提交的。

签署

签署所使用的方法是 HMAC-SHA 1。

HMAC 的全称是 Hash-based message authentication code (哈希运算消息认证码),以一个密钥和一个消息为输入,生成一个消息摘要作为输出,这里以 SHA 1 作为消息输入。使用 HMAC 的原因是:只有用户本身知道正确的输入密钥,因此会得到唯一的输出。

其算法可以简单表示为:

hmac = SHA1(secret + SHA1(secret + input))

事实上,TOTP 是 HMAC-OTP(基于 HMAC 的一次密码生成)的超集,区别是 TOTP 以当前时间作为输入,而 HMAC-OTP 以自增计算器作为输入,该计数器使用时需要进行同步。

算法

首先,要进行密钥的 base 32 加密。虽然谷歌上的密钥格式是带空格的,不过 base 32 拒绝空格输入,并只允许大写。所以要作如下处理:

original_secret = xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx 
secret = BASE32_DECODE(TO_UPPERCASE(REMOVE_SPACES(original_secret)))

第二步要获取当前时间值,即获取当前时间戳,单位是秒而不是毫秒。在 Google Authenticator 中,input 值拥有一个有效期。因为如果直接根据时间进行计算,结果将时刻发生改变,那么将很难进行复用。Google Authenticator 默认使用 30 秒作为有效期 (时间片),最后 input 的取值为从 Unix epoch(1970 年 1 月 1 日 00:00:00)来经历的 30 秒的个数。

input = CURRENT_UNIX_TIME() / 30

最后一步是进行 HMAC-SHA 1 运算

original_secret = xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx   
secret = BASE32_DECODE(TO_UPPERCASE(REMOVE_SPACES(original_secret)))   
input = CURRENT_UNIX_TIME() / 30   
hmac = SHA1(secret + SHA1(secret + input))

至此,2 FA 所需的两个因子都已准备就绪了。但是 HMAC 运算后的结果会是 20 字节即 40 位 16 进制数,应该没有人会愿意每次都输入这么长的密码。我们需要的是常规 6 位数字密码。要实现这个愿望,首先要对 20 字节的 SHA 1 进行瘦身。我们把 SHA 1 的最后 4 个比特数(每个数的取值是 0~15)用来做索引号,然后用另外的 4 个字节进行索引。因此,索引号的操作范围是 15+4=19,加上是以零开始,所以能完整表示 20 字节的信息。4 字节的获取方法是:

  • 转化为标准的 32 bit 无符号整数 (4 bytes = 32 bit):
  • large_integer = INT (four_bytes)

最后再进行 7 位数 (1 百万) 取整,就可得到 6 位数字了:

  • large_integer = INT (four_bytes)
  • small_integer = large_integer % 1,000,000

整个目标过程如下:

  1. original_secret = xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
  2. secret = BASE 32_DECODE (TO_UPPERCASE (REMOVE_SPACES (original_secret)))
  3. input = CURRENT_UNIX_TIME () / 30
  4. hmac = SHA 1 (secret + SHA 1 (secret + input))
  5. four_bytes = hmac[LAST_BYTE (hmac): LAST_BYTE (hmac) + 4]
  6. large_integer = INT (four_bytes)
  7. small_integer = large_integer % 1,000,000

Java 算法实现

import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;

import static javax.xml.crypto.dsig.Transform.BASE64;

public class GoogleAuthenticatorUtil {

    private static final int MAX_WINDOW_SIZE = 3;

    private GoogleAuthenticatorUtil() {
    }

    /**
     * 生成随机的加密字符串
     *
     * @return 长度32的随机字符串
     * @throws NoSuchAlgorithmException 算法异常
     */
    public static String generateRandomSecretKey() throws NoSuchAlgorithmException {
        SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
        sr.setSeed(Base64.decodeBase64(BASE64));
        byte[] buffer = sr.generateSeed(20);
        Base32 codec = new Base32();
        byte[] bEncodedKey = codec.encode(buffer);
        return new String(bEncodedKey);
    }

    public static Integer getVerifyCode(String code) {
        byte[] key = new Base32().decode(code);
        long t = (System.currentTimeMillis() / 1000L) / 30L;
        int verifyCode = 0;
        try {
            verifyCode = generateCode(key, t);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            e.printStackTrace();
        }
        return verifyCode;
    }

    public static int generateCode(byte[] key, long t) throws NoSuchAlgorithmException, InvalidKeyException {
        byte[] data = new byte[8];
        long value = t;
        for (int i = 8; i-- > 0; value >>>= 8) {
            data[i] = (byte) value;
        }
        SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1");
        Mac mac = Mac.getInstance("HmacSHA1");
        mac.init(signKey);
        byte[] hash = mac.doFinal(data);
        int offset = hash[20 - 1] & 0xF;
        // We're using a long because Java hasn't got unsigned int.
        long truncatedHash = 0;
        for (int i = 0; i < 4; ++i) {
            truncatedHash <<= 8;
            // We are dealing with signed bytes:
            // we just keep the first byte.
            truncatedHash |= (hash[offset + i] & 0xFF);
        }
        truncatedHash &= 0x7FFFFFFF;
        truncatedHash %= 1000000;
        return (int) truncatedHash;
    }

    /**
     * 生成二维码
     *
     * @param user   账户名
     * @param secret 密钥
     * @param issuer 网站名
     * @return 二维码地址
     */
    public static String getQRBarcode(String user, String secret, String issuer) {
        String format = "otpauth://totp/%s?secret=%s&issuer=%s";
        return String.format(format, issuer + ":" + user, secret, issuer);
    }

    /**
     * 生成可以在谷歌浏览器中直接访问的二维码链接
     *
     * @param site   站点
     * @param user   用户
     * @param secret 加密字符串
     * @return 二维码url地址
     */
    public static String generateGoogleQRBarcodeUrl(String site, String user, String secret) {
        String path = getQRBarcodePath(secret, site, user);
        return "<https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=>" + path;
    }

    public static String getQRBarcodePath(String site, String userName, String secret) {
        String format = "otpauth://totp/%s?secret=%s";
        String user = userName;
        if (StringUtils.isNotBlank(site)) {
            user = site + ":" + userName;
        }
        return String.format(format, user, secret);
    }

    public static boolean checkCode(String secret, long code, long timeMillisecond) {
        int windowSize = MAX_WINDOW_SIZE;
        Base32 codec = new Base32();
        byte[] decodedKey = codec.decode(secret);
        long t = (timeMillisecond / 1000L) / 30L;

        for (int i = -windowSize; i <= windowSize; ++i) {
            long hash = 0;
            try {
                hash = generateCode(decodedKey, t + i);
            } catch (Exception e) {
                e.printStackTrace();
            }
            if (hash == code) {
                return true;
            }
        }
        return false;
    }
}