最近公司新上了堡垒机,为了安全起见,除了密码之外还需要使用Google Authenticator
生成一个6位数字来做认证。作者想起此前也接触过类似的东西,例如银行给的硬件令牌,某些网游使用物品时需要输入6位数字解锁,阿里云的虚拟MFA等。作者对此感到好奇想探究其后如何产生作用。根据一些搜索关键词以后得到了 双因素(2FA)认证
一词,以下简称为2FA,本文将讲解2FA的概念以及算法实现。
本文主要内容转载自阮一峰的双因素认证一文,在此基础上补充了关于如何从hash转换为数字的逻辑以及在 Java
中的实现。
双因素认证概念
一般来说,三种不同类型的证据,可以证明一个人的身份。
- 秘密信息:只有该用户知道、其他人不知道的某种信息,比如密码。
- 个人物品:该用户的私人物品,比如身份证、钥匙。
- 生理特征:该用户的遗传特征,比如指纹、相貌、虹膜等等。
这些证据就称为三种"因素"(factor)。因素越多,证明力就越强,身份就越可靠。
双因素认证就是指,通过认证同时需要两个因素的证据。常见的用户名密码登录属于是单因素认证。
银行卡就是最常见的双因素认证。用户必须同时提供银行卡和密码,才能取到现金。银行的令牌或者是U盾并不会时时刻刻都带着,在现在手机人人都有的情况下,手机才是最好的认证设备。
短信验证码就属于是基于手机的双因素认证的一种。但是,短消息是不安全的,容易被拦截和伪造,SIM 卡也可以克隆。已经有案例,先伪造身份证,再申请一模一样的手机号码,把钱转走。
本文主要讲解TOTP
TOTP概念
TOTP
的全称是"基于时间的一次性密码"(Time-based One-time Password)。它是公认的可靠解决方案,已经写入国际标准 RFC6238。
TOTP
的使用大致如下:
- 当开启双因子认证后,服务端会首先生成一个密钥。密钥的表现形式可以是多种多样的,例如可以是二维码。
- 服务端要求客户的手机或其他设备上使用app来留存该密钥,常用软件有
Google Authenticator
- 登录时,服务端要求用户填入固定位数的数字,该数字由持有密钥的设备生成,持有密钥的设备将会使用密钥和当前设备的时间戳生成一个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
,用该值作为一个偏移量。因为 SHA
在 HASH
后生成的长度都至少是大于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 &quot;first&quot;
byte[] ret = new byte[bArray.length - 1];
for (int i = 0; i &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, "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() &lt; 16)
time = &quot;0&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; 0xf;
int binary =
((hash[offset] &amp; 0x7f) &lt;&lt; 24) |
((hash[offset + 1] &amp; 0xff) &lt;&lt; 16) |
((hash[offset + 2] &amp; 0xff) &lt;&lt; 8) |
(hash[offset + 3] &amp; 0xff);
int otp = binary % DIGITS_POWER[codeDigits];
result = Integer.toString(otp);
while (result.length() &lt; codeDigits) {
result = &quot;0&quot; + 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 = &quot;0&quot;;
DateFormat df = new SimpleDateFormat(&quot;yyyy-MM-dd HH:mm:ss&quot;);
df.setTimeZone(TimeZone.getTimeZone(&quot;UTC&quot;));
try {
System.out.println(
&quot;+---------------+-----------------------+&quot; +
&quot;------------------+--------+--------+&quot;);
System.out.println(
&quot;| Time(sec) | Time (UTC format) &quot; +
&quot;| Value of T(Hex) | TOTP | Mode |&quot;);
System.out.println(
&quot;+---------------+-----------------------+&quot; +
&quot;------------------+--------+--------+&quot;);
for (int i = 0; i &amp;lt; testTime.length; i++) {
long T = (testTime[i] - T0) / X;
steps = Long.toHexString(T).toUpperCase();
while (steps.length() &amp;lt; 16) steps = &amp;quot;0&amp;quot; + steps;
String fmtTime = String.format(&amp;quot;%1$-11s&amp;quot;, testTime[i]);
String utcTime = df.format(new Date(testTime[i] * 1000));
System.out.print(&amp;quot;| &amp;quot; + fmtTime + &amp;quot; | &amp;quot; + utcTime +
&amp;quot; | &amp;quot; + steps + &amp;quot; |&amp;quot;);
System.out.println(generateTOTP(seed, steps, &amp;quot;6&amp;quot;,
&amp;quot;HmacSHA1&amp;quot;) + &amp;quot;| SHA1 |&amp;quot;);
System.out.print(&amp;quot;| &amp;quot; + fmtTime + &amp;quot; | &amp;quot; + utcTime +
&amp;quot; | &amp;quot; + steps + &amp;quot; |&amp;quot;);
System.out.println(generateTOTP(seed32, steps, &amp;quot;6&amp;quot;,
&amp;quot;HmacSHA256&amp;quot;) + &amp;quot;| SHA256 |&amp;quot;);
System.out.print(&amp;quot;| &amp;quot; + fmtTime + &amp;quot; | &amp;quot; + utcTime +
&amp;quot; | &amp;quot; + steps + &amp;quot; |&amp;quot;);
System.out.println(generateTOTP(seed64, steps, &amp;quot;6&amp;quot;,
&amp;quot;HmacSHA512&amp;quot;) + &amp;quot;| SHA512 |&amp;quot;);
System.out.println(
&amp;amp;quot;+---------------+-----------------------+&amp;amp;quot; +
&amp;amp;quot;------------------+--------+--------+&amp;amp;quot;);
}
} catch (final Exception e) {
System.out.println(&quot;Error : &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)。 双因素认证还有一个最大的问题,那就是帐户的恢复。 一旦忘记密码或者遗失手机,想要恢复登录,势必就要绕过双因素认证,这就形成了一个安全漏洞。除非准备两套双因素认证,一套用来登录,另一套用来恢复账户。
参考链接
- Multi-factor authentication, by Wikipedia
- Time-based One-time Password Algorithm, by Wikipedia
- Enabling Two-Factor Authentication For Your Web Application, by Bozhidar Bozhanov
- simontabor/2fa, by Simon Tabor
- 双因素认证教程, by 阮一峰