动态密码,亦称一次性密码(One Time Password, 简称 OTP),是一种高效简单又比较安全的密码生成算法,在我们的生活以及工作中随处可见。
1、动态密码背景介绍
动态密码是指随着某一事件(密码被使用、一定的时间流逝等)的发生而重新生成的密码,因为动态密码本身最大优点是防重复执行攻击(replay attack),它能很好地避免类似静态密码可能被暴力破解等的缺陷,现实运用中,一般采用“静态密码+动态密码”相结合的双因素认证,我们也称二步验证。
而动态密码其实很早就出现在我们的生活里了,在移动支付发展起来之前,网银是当时最为流行的在线支付渠道,当时银行为了确保大家的网银账号支付安全,都会给网银客户配发动态密码卡。
比如中国银行电子口令卡(按时间差定时生成新密码,口令卡自带电池,可保证连续使用几年),或者工商银行的电子银行口令卡(网银支付网页每次生成不同的行列序号,用户根据指定行列组合刮开密码卡上的涂层获取密码,密码使用后失效),又或者银行强制要求的短信验证码,这些都可以纳入动态密码的范畴。
而随着移动互联网的发展以及移动设备的智能化的不断提高,设备间的同步能力大幅提升,以前依赖独立设备的动态密码生成技术很快演变成了手机上的动态密码生成软件,以手机软件的形式生成动态密码的方式极大提高了动态密码的便携性,一个用户一个手机就可以管理任意多个动态密码的生成,这也使得在网站上推动二步验证减少了很多阻力,因为以往客户可能因为使用口令卡太麻烦,而拒绝打开二步验证机制,从而让自己的账号暴露在风险之下。最为知名的动态密码生成软件,当属 Google 的 Authenticator APP。
2、动态密码算法探索
2.1、常见的动态密码有两类
计次使用:
计次使用的 OTP 产出后,可在不限时间内使用,知道下次成功使用后,计数器加 1,生成新的密码。用于实现计次使用动态密码的算法叫 HOTP;
计时使用:
计时使用的 OTP 则可设定密码有效时间,从 30 秒到两分钟不等,而 OTP 在进行认证之后即废弃不用,下次认证必须使用新的密码。用于实现计时使用动态密码的算法叫 TOTP。
动态密码的基本认证原理是在认证双方共享密钥,也称种子密钥,并使用的同一个种子密钥对某一个事件计数、或时间值进行密码算法计算,使用的算法有对称算法、HASH、HMAC 等,这个是所有动态密码算法实现的基础。
2.2、计算 OTP 串的公式
OTP(K,C) = Truncate(HMAC-SHA-1(K,C))
其中,
K 表示秘钥串;
C 是一个数字,表示随机数;
HMAC-SHA-1 表示使用 SHA-1 做 HMAC;
Truncate 是一个函数,就是怎么截取加密后的串,并取加密后串的哪些字段组成一个数字。
对 HMAC-SHA-1 方式加密来说,Truncate 实现如下。
HMAC-SHA-1 加密后的长度得到一个 20 字节的密串;
取这个20字节的密串的最后一个字节,取这字节的低4位,作为截取加密串的下标偏移量;
按照下标偏移量开始,获取4个字节,按照大端方式组成一个整数;
截取这个整数的后6位或者8位转成字符串返回。
Java 代码实现:
public static String generateOTP(String K,
String C,
String returnDigits,
String crypto){
int codeDigits = Integer.decode(returnDigits).intValue();
String result = null;
// K是密码
// C是产生的随机数
// crypto是加密算法 HMAC-SHA-1
byte[] hash = hmac_sha(crypto, K, C);
// hash为20字节的字符串
// put selected bytes into result int
// 获取hash最后一个字节的低4位,作为选择结果的开始下标偏移
int offset = hash[hash.length - 1] & 0xf;
// 获取4个字节组成一个整数,其中第一个字节最高位为符号位,不获取,使用0x7f
int binary =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
// 获取这个整数的后6位(可以根据需要取后8位)
int otp = binary % 1000000;
// 将数字转成字符串,不够6位前面补0
result = Integer.toString(otp);
while (result.length() < codeDigits) {
result = "0" + result;
}
return result;
}
返回的结果就是看到一个数字的动态密码。
2.1、HOTP
HOTP 算法,全称是“An HMAC-Based One-Time Password Algorithm”,是一种基于事件计数的一次性密码生成算法,详细的算法介绍可以查看 RFC 4226。
算法本身可以用两条简短的表达式描述:
HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
PWD(K,C,digit) = HOTP(K,C) mod 10^Digit
上式中:
K 代表我们在认证服务器端以及密码生成端(客户设备)之间共享的密钥,在 RFC 4226 中,作者要求共享密钥最小长度是 128 位,而作者本身推荐使用 160 位长度的密钥。
C 表示事件计数的值,8 字节的整数,称为移动因子(moving factor),需要注意的是,这里的 C 的整数值需要用二进制的字符串表达,比如某个事件计数为 3,则 C 是 "11"(此处省略了前面的二进制的数字 0)。
HMAC-SHA-1 表示对共享密钥以及移动因子进行 HMAC 的 SHA1 算法加密,得到 160 位长度(20 字节)的加密结果
Truncate 即截断函数。
digit 指定动态密码长度,比如我们常见的都是 6 位长度的动态密码。
由于 SHA-1 算法是既有算法,不是我们讨论重点,故而 Truncate 函数就是整个算法中最为关键的部分了。
以下引用 Truncate 函数的步骤说明:
DT(String) // String = String[0]...String[19]
Let OffsetBits be the low-order 4 bits of String[19]
Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15
Let P = String[OffSet]...String[OffSet+3]
Return the Last 31 bits of P
结合上面的公式理解,大概的描述就是:
第一步通过 SHA-1 算法加密得到的 20 字节长度的结果中选取最后一个字节的低字节位的 4 位(注意:动态密码算法中采用的大端(big-endian)存储);
将这 4 位的二进制值转换为无标点数的整数值,得到 0 到 15(包含 0 和 15)之间的一个数,这个数字作为 20 个字节中从 0 开始的偏移量;
接着从指定偏移位开始,连续截取 4 个字节(32 位),最后返回 32 位中的后面 31 位。
回到算法本身,在获得 31 位的截断结果之后,我们将其又转换为无标点的大端表示的整数值,这个值的取值范围是 0 ~ 2^31,也即 0 ~ 2.147483648E9,最后我们将这个数对 10 的乘方(digit 指数范围 1-10)取模,得到一个余值,对其前面补 0 得到指定位数的字符串。
代码示例:
require 'openssl'
def hotp(secret, counter, digits = 6)
hash = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'), secret, int_to_bytestring(counter)) # SHA-1 算法加密
"%0#{digits}i" % (truncate(hash) % 10**digits) # 取模获取指定长度数字密码
end
def truncate(string)
offset = string.bytes.last & 0xf # 取最后一个字节
partial = string.bytes[offset..offset+3] # 从偏移量开始,连续取 4 个字节
partial.pack("C*").unpack("N").first & 0x7fffffff # 取后面 31 位结果后得到整数
end
def int_to_bytestring(int, padding = 8)
result = []
until int == 0
result << (int & 0xFF).chr
int >>= 8
end
result.reverse.join.rjust(padding, 0.chr)
end
上面的算法实现代码量很少,核心都是按照算法描述进行多个掩码运算跟位操作而已。
密码失效机制:
从上面的分析可以看到,一个动态密码的生成,取决于共享密钥以及移动因子的值,而共享密钥是保持不变的,最终就只有移动因子决定了密码的生成结果。
所以在 HOTP 算法中,要求每次密码验证成功后,认证服务器端以及密码生成器(客户端)都要将计数器的值加 1,已确保得到新的密码。
但是在这里就会引入一个问题,假如认证服务器端与密码生成器之间由于通信故障或者其他意外情况,导致两边计数器的值不同步了,那么就会导致两边生成的密码无法正确匹配。
为了解决这个问题,算法在分析中建议认证服务器端在验证密码失败后,可以主动尝试计数器减 1 之后重新生成的新密码是否与客户端提交密码一致,如果是,则可以认定是客户端计数器未同步导致,这种情况下可以通过验证,并且要求客户端重新同步计数器的值。
其实 HOTP 的算法比我在阅读算法前所想象的要简洁得多,而且仍然足够强健。算法本身巧妙利用了加密算法对共享密钥和计数器进行加密,确保这两个动态密码生成因子不被篡改,接着通过一个 truncate 函数随机得到一个最长 10 位的 10 进制整数,最终实现对 1 - 10 位长度动态密码的支持。
算法本身的简洁也确保了算法本身可以在各种设备上实现。
2.2、TOTP
TOTP 算法,全称是 TOTP: Time-Based One-Time Password Algorithm,其基于 HOTP 算法实现,核心是将移动因子从 HOTP 中的事件计数改为时间差。
完整的 TOTP 算法的说明可以查看 RFC 6238,其公式描述也非常简单:
TOTP = HOTP(K, T) // T is an integer
and represents the number of time steps between the initial counter
time T0 and the current Unix time
More specifically, T = (Current Unix time - T0) / X, where the
default floor function is used in the computation.
通常来说,TOTP 中所使用的时间差都是当前时间戳,TOTP 将时间差除以时间窗口(密码有效期,默认 30 秒)得到时间窗口计数,以此作为动态密码算法的移动因子,这样基于 HOTP 算法就能方便得到基于时间的动态密码了。
安全性:
该算法的安全性和健壮性完全依赖于其关键实现环节 HOTP。
安全性分析的结果是:
在所有的测试中,该算法的结果均匀的、独立的分布。
这个分析显示,最好的攻击和破解 TOTP(HOTP)的 方法是暴力破解。而在算法要求环节,要求 key 必须有足够的随机性。
时延兼容:
在同一个步长内,动态密码生成的结果是一样的。
当一个验证系统获得这个动态密码的时候,它并不知道动态密码的生产者是在哪个步长内产生的密码。
由于网络的原因,客户端生成密码的时间和服务器接受密码的时间可能差距会很大,很有可能使得这 2 个时间不在同一个步长内。
当一个动态密码产生在一个步长的结尾,服务器收到的密码很有可能在下一个步长的开始。
验证系统应该设置一个策略允许动态密码的传输时延,不应该只验证当前步长的动态密码,还应该验证之前几个步长的动态密码。
但越大的传输时延窗口设置,就会带来越大的风险被攻击,我们推荐最多设置一个时延窗口来兼容传输延时。
步长设置
步长大小的设置,直接影响安全性和可用性:
一个越大的步长,就会导致一个越大的窗口被攻击。
当一个动态密码被生成而且在其有效期内暴露在第三方环境下,那么第三方系统就可以在该动态密码无效前使用这个密码。
我们推荐默认的步长时间是 30s,这个默认值是在权衡了安全性和可用性的基础上提出的。
下一个动态密码肯定会在下一个步长生成,用户必须等待当前步长的结束。
这个等待时间的理想值会随着步长的设置而增大。一个太长的窗口设置不使用网络用户登录这种场景,用户可能等不了一个步长的时间,就放弃登录。
代码示例:
require 'hotp'
def totp(secret, digits = 6, step = 30, initial_time = 0)
steps = (Time.now.to_i - initial_time) / step
hotp(secret, steps, digits)
end
问题探讨:
HOTP 算法中的主要问题是计数器的同步,而 TOTP 也不例外,只是问题在于服务器端与客户端之间时间的同步,由于现在互联网的发达,加上移动设备一般都会按照网络时间设置设备时间,基本上时间的相对同步都不是问题;
时间同步的另一个问题其实是边界问题,假如客户端生成密码的时间刚好是第 29 秒,而由于网络延迟等原因,服务器受理验证时刚好是下一个时间窗口的第 1 秒,这个时候会导致密码验证失效。
于是,TOTP 算法在其算法讨论中,也建议服务器在验证密码失败之后,可以尝试将自身的时间窗口值减 1 之后重新生成密码比对,如果验证通过,说明验证不通过是时间窗口的边界问题导致,这个时候可以认为密码验证通过。
基于时间的动态密码的另一个好处是避免了基于计数器的多设备间的计数器同步问题,因为每台设备以及服务端都可以自行与网络时间(共同时间标准)校准,而无需依赖服务端的时间。
在 Google Authenticator 的开源项目的 README 里有明确提到:
These implementations support the HMAC-Based One-time Password (HOTP) algorithm specified in RFC 4226 and the Time-based One-time Password (TOTP) algorithm specified in RFC 6238.
3、示例:Google Authenticator
实现:
1)Prover与Verifier之间必须时钟同步;
2)Prover与Verifier之间必须共享密钥;
3)Prover与Verifier之间必须使用相同的时间步长
算法:TOTP = Truncate(HMAC-SHA-1(K, (T - T0) / X))
K 共享密钥
T 时间
T0 开始计数的时间步长
X 时间步长
代码:
/**
Copyright (c) 2011 IETF Trust and the persons identified as
authors of the code. All rights reserved.
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");
}
/**
* 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, "8", "HmacSHA1")
+ "| SHA1 |");
System.out.print("| " + fmtTime + " | " + utcTime + " | "
+ steps + " |");
System.out.println(generateTOTP(seed32, steps, "8",
"HmacSHA256") + "| SHA256 |");
System.out.print("| " + fmtTime + " | " + utcTime + " | "
+ steps + " |");
System.out.println(generateTOTP(seed64, steps, "8",
"HmacSHA512") + "| SHA512 |");
System.out.println("+---------------+-----------------------+"
+ "------------------+--------+--------+");
}
} catch (final Exception e) {
System.out.println("Error : " + e);
}
}
}
效果: