2fa.jpg

最近公司新上了堡垒机,为了安全起见,除了密码之外还需要使用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, &quot;RAW&quot;); 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 &quot;0&quot; can be converted byte[] bArray = new BigInteger(&quot;10&quot; + hex, 16).toByteArray();

// Copy all the REAL bytes, not the &amp;quot;first&amp;quot;
byte[] ret = new byte[bArray.length - 1];
for (int i = 0; i &amp;lt; 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, &quot;HmacSHA1&quot;); }

/**

  • 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, &quot;HmacSHA256&quot;); }

/**

  • 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, &quot;HmacSHA512&quot;); }

public static String byte2Binary(byte x) { StringBuffer sb = new StringBuffer(); short shift = 7; do{ int i = x &gt;&gt;&gt; shift &amp; 0x1; sb.append(i); shift --; }while (shift &gt;=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() &amp;lt; 16)
    time = &amp;quot;0&amp;quot; + 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] &amp;amp; 0xf;

int binary = ((hash[offset] &amp;amp; 0x7f) &amp;lt;&amp;lt; 24) | ((hash[offset + 1] &amp;amp; 0xff) &amp;lt;&amp;lt; 16) | ((hash[offset + 2] &amp;amp; 0xff) &amp;lt;&amp;lt; 8) | (hash[offset + 3] &amp;amp; 0xff);

int otp = binary % DIGITS_POWER[codeDigits];

result = Integer.toString(otp); while (result.length() &amp;lt; codeDigits) { result = &amp;quot;0&amp;quot; + result; } return result;

}

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

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

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

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

}

} catch (final Exception e) { System.out.println(&amp;quot;Error : &amp;quot; + 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)。 双因素认证还有一个最大的问题,那就是帐户的恢复。 一旦忘记密码或者遗失手机,想要恢复登录,势必就要绕过双因素认证,这就形成了一个安全漏洞。除非准备两套双因素认证,一套用来登录,另一套用来恢复账户。

参考链接