双因素认证

封面图

最近公司新上了堡垒机,为了安全起见,除了密码之外还需要使用Google Authenticator 生成一个6位数字来做认证。作者想起此前也接触过类似的东西,例如银行给的硬件令牌,某些网游使用物品时需要输入6位数字解锁,阿里云的虚拟MFA等。作者对此感到好奇想探究其后如何产生作用。根据一些搜索关键词以后得到了 双因素(2FA)认证 一词,以下简称为2FA,本文将讲解2FA的概念以及算法实现。

本文主要内容转载自阮一峰的双因素认证一文,在此基础上补充了关于如何从hash转换为数字的逻辑以及在 Java 中的实现。

双因素认证概念

一般来说,三种不同类型的证据,可以证明一个人的身份。

  • 秘密信息:只有该用户知道、其他人不知道的某种信息,比如密码。
  • 个人物品:该用户的私人物品,比如身份证、钥匙。
  • 生理特征:该用户的遗传特征,比如指纹、相貌、虹膜等等。

这些证据就称为三种"因素"(factor)。因素越多,证明力就越强,身份就越可靠。

双因素认证就是指,通过认证同时需要两个因素的证据。常见的用户名密码登录属于是单因素认证。

银行卡就是最常见的双因素认证。用户必须同时提供银行卡和密码,才能取到现金。银行的令牌或者是U盾并不会时时刻刻都带着,在现在手机人人都有的情况下,手机才是最好的认证设备。

短信验证码就属于是基于手机的双因素认证的一种。但是,短消息是不安全的,容易被拦截和伪造,SIM 卡也可以克隆。已经有案例,先伪造身份证,再申请一模一样的手机号码,把钱转走。

本文主要讲解TOTP

TOTP概念

TOTP 的全称是"基于时间的一次性密码"(Time-based One-time Password)。它是公认的可靠解决方案,已经写入国际标准 RFC6238

TOTP 的使用大致如下:

  1. 当开启双因子认证后,服务端会首先生成一个密钥。密钥的表现形式可以是多种多样的,例如可以是二维码。
  2. 服务端要求客户的手机或其他设备上使用app来留存该密钥,常用软件有Google Authenticator
  3. 登录时,服务端要求用户填入固定位数的数字,该数字由持有密钥的设备生成,持有密钥的设备将会使用密钥和当前设备的时间戳生成一个hash并转换为固定位数的数字,该数字默认有效期30秒,服务端也用同样的方法生成固定位数的数字,如果数字一样则认为是登录成功,否则登录失败。

TOTP算法

最让作者着迷的是只是两边共享了同一把密钥,是怎么样的计算能够生成同样的信息呢?

答案就是下面的公式。

TC = floor((unixtime(now) − unixtime(T0)) / TS)

上面的公式中, TC 表示一个时间计数器,unixtime(now)是当前 Unix 时间戳,unixtime(T0)是约定的起始时间点的时间戳,默认是0,也就是1970年1月1日。 TS 则是哈希有效期的时间长度,默认是30秒。因此,上面的公式就变成下面的形式。

TC = floor(unixtime(now) / 30)

所以,只要在 30 秒以内, TC 的值都是一样的。前提是服务器和手机的时间必须同步。

接下来,就可以算出哈希了。

TOTP = HASH(SecretKey, TC)

上面代码中,HASH就是约定的哈希函数,默认是 SHA-1。

从编程角度上来说经过 HASH 后得到的只是一个字节数组,如何转换为固定位数的数字呢?

RFC6238 文档中提供了具体实现方法,方法如下

// (1)
int offset = hash[hash.length - 1] & 0xf;
// (2)
int binary =
    ((hash[offset] & 0x7f) << 24) |
        ((hash[offset + 1] & 0xff) << 16) |
        ((hash[offset + 2] & 0xff) << 8) |
  (hash[offset + 3] & 0xff);
//(3)
int otp = binary % DIGITS_POWER[codeDigits];

(1)取hash后的字节数组的最后一位 和 0xf 做与运算,得到了一个小于等于15的值 offset,用该值作为一个偏移量。因为 SHAHASH 后生成的长度都至少是大于15,所以不会存在访问溢出的情况。

(2)取 HASH 字节数组中的 第offset 位和 0x7f 做与运算得到的结果左移24位得到 A,取 HASH 字节数组中的 第offset+1 位和 0xff 做与运算得到的结果左移16位得到 B,取 HASH 字节数组中的 第offset+2 位和 0xff 做与运算得到的结果左移8位得到 C,取 HASH 字节数组中的 第offset+3 位和 0xff 做与运算得到 D,最后 A B C D 做或运算得到一个32位整数。

假设offset为0,值都是二进制表示
hash[offset]=11000001,
hash[offset+1]=00111001,
hash[offset+2]=01111110,
hash[offset+3]=11101010
A = (11000001  & 01111111) <<24 = 01000001000000000000000000000000
B = (00111001& 11111111)  << 16 = 00000000001110010000000000000000
C = (01111110& 11111111)    <<8 = 00000000000000000111111000000000
D = (11101010& 11111111)        = 00000000000000000000000011101010
A|B|C|D                         = 01000001001110010111111011101010 = 1094287082

(3) 完成取模, codeDigits 指的是需要取模多少位,也就是需要转换为多长的数字。

1094287082 % 100000 = 287082

至此转换过程结束。

TOTP的Java实现

/**
 * Copyright (c) 2011 IETF Trust and the persons identified as
 * authors of the code. All rights reserved.
 * <p>
 * Redistribution and use in source and binary forms, with or without
 * modification, is permitted pursuant to, and subject to the license
 * terms contained in, the Simplified BSD License set forth in Section
 * 4.c of the IETF Trust's Legal Provisions Relating to IETF Documents
 * (http://trustee.ietf.org/license-info).
 */

import java.lang.reflect.UndeclaredThrowableException;
import java.security.GeneralSecurityException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
import java.util.TimeZone;

/**
 * This is an example implementation of the OATH
 * TOTP algorithm.
 * Visit www.openauthentication.org for more information.
 *
 * @author Johan Rydell, PortWise, Inc.
 */

public class TOTP {

    private TOTP() {
    }

    /**
     * This method uses the JCE to provide the crypto algorithm.
     * HMAC computes a Hashed Message Authentication Code with the
     * crypto hash algorithm as a parameter.
     *
     * @param crypto: the crypto algorithm (HmacSHA1, HmacSHA256,
     *                             HmacSHA512)
     * @param keyBytes: the bytes to use for the HMAC key
     * @param text: the message or text to be authenticated
     */

    private static byte[] hmac_sha(String crypto, byte[] keyBytes,
                                   byte[] text) {
        try {
            Mac hmac;
            hmac = Mac.getInstance(crypto);
            SecretKeySpec macKey =
                new SecretKeySpec(keyBytes, "RAW");
            hmac.init(macKey);
            return hmac.doFinal(text);
        } catch (GeneralSecurityException gse) {
            throw new UndeclaredThrowableException(gse);
        }
    }

    /**
     * This method converts a HEX string to Byte[]
     *
     * @param hex: the HEX string
     *
     * @return: a byte array
     */

    private static byte[] hexStr2Bytes(String hex) {
        // Adding one byte to get the right conversion
        // Values starting with "0" can be converted
        byte[] bArray = new BigInteger("10" + hex, 16).toByteArray();

        // Copy all the REAL bytes, not the "first"
        byte[] ret = new byte[bArray.length - 1];
        for (int i = 0; i < ret.length; i++)
            ret[i] = bArray[i + 1];
        return ret;
    }

    private static final int[] DIGITS_POWER
        // 0 1  2   3    4     5      6       7        8
        = {1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000};

    /**
     * This method generates a TOTP value for the given
     * set of parameters.
     *
     * @param key: the shared secret, HEX encoded
     * @param time: a value that reflects a time
     * @param returnDigits: number of digits to return
     *
     * @return: a numeric String in base 10 that includes
     *              {@link truncationDigits} digits
     */

    public static String generateTOTP(String key,
                                      String time,
                                      String returnDigits) {
        return generateTOTP(key, time, returnDigits, "HmacSHA1");
    }

    /**
     * This method generates a TOTP value for the given
     * set of parameters.
     *
     * @param key: the shared secret, HEX encoded
     * @param time: a value that reflects a time
     * @param returnDigits: number of digits to return
     *
     * @return: a numeric String in base 10 that includes
     *              {@link truncationDigits} digits
     */

    public static String generateTOTP256(String key,
                                         String time,
                                         String returnDigits) {
        return generateTOTP(key, time, returnDigits, "HmacSHA256");
    }

    /**
     * This method generates a TOTP value for the given
     * set of parameters.
     *
     * @param key: the shared secret, HEX encoded
     * @param time: a value that reflects a time
     * @param returnDigits: number of digits to return
     *
     * @return: a numeric String in base 10 that includes
     *              {@link truncationDigits} digits
     */

    public static String generateTOTP512(String key,
                                         String time,
                                         String returnDigits) {
        return generateTOTP(key, time, returnDigits, "HmacSHA512");
    }

    public static String byte2Binary(byte x) {
        StringBuffer sb = new StringBuffer();
        short shift = 7;
        do{
            int i = x >>> shift & 0x1;
            sb.append(i);
            shift --;
        }while (shift >=0);
        return sb.toString();
    }

    /**
     * This method generates a TOTP value for the given
     * set of parameters.
     *
     * @param key: the shared secret, HEX encoded
     * @param time: a value that reflects a time
     * @param returnDigits: number of digits to return
     * @param crypto: the crypto function to use
     *
     * @return: a numeric String in base 10 that includes
     *              {@link truncationDigits} digits
     */

    public static String generateTOTP(String key,
                                      String time,
                                      String returnDigits,
                                      String crypto) {
        int codeDigits = Integer.decode(returnDigits).intValue();
        String result = null;

        // Using the counter
        // First 8 bytes are for the movingFactor
        // Compliant with base RFC 4226 (HOTP)
        while (time.length() < 16)
            time = "0" + time;

        // Get the HEX in a Byte[]
        byte[] msg = hexStr2Bytes(time);
        byte[] k = hexStr2Bytes(key);

        byte[] hash = hmac_sha(crypto, k, msg);

        // put selected bytes into result int
        int offset = hash[hash.length - 1] & 0xf;
  
        int binary =
            ((hash[offset] & 0x7f) << 24) |
                ((hash[offset + 1] & 0xff) << 16) |
                ((hash[offset + 2] & 0xff) << 8) |
                (hash[offset + 3] & 0xff);

        int otp = binary % DIGITS_POWER[codeDigits];

        result = Integer.toString(otp);
        while (result.length() < codeDigits) {
            result = "0" + result;
        }
        return result;
    }

    public static void main(String[] args) {
        // Seed for HMAC-SHA1 - 20 bytes
        String seed = "3132333435363738393031323334353637383930";
        // Seed for HMAC-SHA256 - 32 bytes
        String seed32 = "3132333435363738393031323334353637383930" +
            "313233343536373839303132";
        // Seed for HMAC-SHA512 - 64 bytes
        String seed64 = "3132333435363738393031323334353637383930" +
            "3132333435363738393031323334353637383930" +
            "3132333435363738393031323334353637383930" +
            "31323334";
        long T0 = 0;
        long X = 30;
        long testTime[] = {59L, 1111111109L, 1111111111L,
            1234567890L, 2000000000L, 20000000000L};

        String steps = "0";
        DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        df.setTimeZone(TimeZone.getTimeZone("UTC"));

        try {
            System.out.println(
                "+---------------+-----------------------+" +
                    "------------------+--------+--------+");
            System.out.println(
                "|  Time(sec)    |   Time (UTC format)   " +
                    "| Value of T(Hex)  |  TOTP  | Mode   |");
            System.out.println(
                "+---------------+-----------------------+" +
                    "------------------+--------+--------+");

            for (int i = 0; i < testTime.length; i++) {
                long T = (testTime[i] - T0) / X;
                steps = Long.toHexString(T).toUpperCase();
                while (steps.length() < 16) steps = "0" + steps;
                String fmtTime = String.format("%1$-11s", testTime[i]);
                String utcTime = df.format(new Date(testTime[i] * 1000));
                System.out.print("|  " + fmtTime + "  |  " + utcTime +
                    "  | " + steps + " |");
                System.out.println(generateTOTP(seed, steps, "6",
                    "HmacSHA1") + "| SHA1   |");
                System.out.print("|  " + fmtTime + "  |  " + utcTime +
                    "  | " + steps + " |");
                System.out.println(generateTOTP(seed32, steps, "6",
                    "HmacSHA256") + "| SHA256 |");
                System.out.print("|  " + fmtTime + "  |  " + utcTime +
                    "  | " + steps + " |");
                System.out.println(generateTOTP(seed64, steps, "6",
                    "HmacSHA512") + "| SHA512 |");

                System.out.println(
                    "+---------------+-----------------------+" +
                        "------------------+--------+--------+");
            }
        } catch (final Exception e) {
            System.out.println("Error : " + e);
        }
    }
}

输出结果:

+---------------+-----------------------+------------------+--------+--------+
|  Time(sec)    |   Time (UTC format)   | Value of T(Hex)  |  TOTP  | Mode   |
+---------------+-----------------------+------------------+--------+--------+
|  59           |  1970-01-01 00:00:59  | 0000000000000001 |287082| SHA1   |
|  59           |  1970-01-01 00:00:59  | 0000000000000001 |119246| SHA256 |
|  59           |  1970-01-01 00:00:59  | 0000000000000001 |693936| SHA512 |
+---------------+-----------------------+------------------+--------+--------+
|  1111111109   |  2005-03-18 01:58:29  | 00000000023523EC |081804| SHA1   |
|  1111111109   |  2005-03-18 01:58:29  | 00000000023523EC |084774| SHA256 |
|  1111111109   |  2005-03-18 01:58:29  | 00000000023523EC |091201| SHA512 |
+---------------+-----------------------+------------------+--------+--------+
|  1111111111   |  2005-03-18 01:58:31  | 00000000023523ED |050471| SHA1   |
|  1111111111   |  2005-03-18 01:58:31  | 00000000023523ED |062674| SHA256 |
|  1111111111   |  2005-03-18 01:58:31  | 00000000023523ED |943326| SHA512 |
+---------------+-----------------------+------------------+--------+--------+
|  1234567890   |  2009-02-13 23:31:30  | 000000000273EF07 |005924| SHA1   |
|  1234567890   |  2009-02-13 23:31:30  | 000000000273EF07 |819424| SHA256 |
|  1234567890   |  2009-02-13 23:31:30  | 000000000273EF07 |441116| SHA512 |
+---------------+-----------------------+------------------+--------+--------+
|  2000000000   |  2033-05-18 03:33:20  | 0000000003F940AA |279037| SHA1   |
|  2000000000   |  2033-05-18 03:33:20  | 0000000003F940AA |698825| SHA256 |
|  2000000000   |  2033-05-18 03:33:20  | 0000000003F940AA |618901| SHA512 |
+---------------+-----------------------+------------------+--------+--------+
|  20000000000  |  2603-10-11 11:33:20  | 0000000027BC86AA |353130| SHA1   |
|  20000000000  |  2603-10-11 11:33:20  | 0000000027BC86AA |737706| SHA256 |
|  20000000000  |  2603-10-11 11:33:20  | 0000000027BC86AA |863826| SHA512 |
+---------------+-----------------------+------------------+--------+--------+

总结

双因素认证的优点在于,比单纯的密码登录安全得多。就算密码泄露,只要手机还在,账户就是安全的。各种密码破解方法,都对双因素认证无效。 缺点在于,登录多了一步,费时且麻烦,用户会感到不耐烦。而且,它也不意味着账户的绝对安全,入侵者依然可以通过盗取 cookie 或 token,劫持整个对话(session)。 双因素认证还有一个最大的问题,那就是帐户的恢复。 一旦忘记密码或者遗失手机,想要恢复登录,势必就要绕过双因素认证,这就形成了一个安全漏洞。除非准备两套双因素认证,一套用来登录,另一套用来恢复账户。

参考链接