一次性密码 OTP
# 概念
一次性密码(One Time Password,简称OTP),又称“一次性口令”,是指只能使用一次的密码。一次性密码是根据专门算法、每隔60秒生成一个不可预测的随机数字组合,iKEY一次性密码已在金融 (opens new window)、电信 (opens new window)、网游 (opens new window)等领域被广泛应用,有效地保护了用户的安全。
一般的静态密码在安全性上容易因为木马 (opens new window)与键盘侧录程序 (opens new window)等而被窃取,而只要花上相当程度的时间,也有可能被暴力破解 (opens new window)。为了解决一般密码容易遭到破解情况,因此开发出一次性密码的解决方案。
在平时生活中,我们接触一次性密码的场景非常多,比如在登录账号、找回密码,更改密码和转账操作等等这些场景,其中一些常用到的方式有:
- 手机短信+短信验证码;
- 邮件+邮件验证码;
- 认证器软件+验证码,比如Microsoft Authenticator App,Google Authenticator App等等;
- 硬件+验证码:比如网银的电子密码器;
这些场景的流程一般都是在用户提供了账号+密码的基础上,让用户再提供一个一次性的验证码来提供一层额外的安全防护。通常情况下,这个验证码是一个6-8位的数字,只能使用一次或者仅在很短的时间内可用(比如5分钟以内)
# 形式
OTP从技术来分有三种形式, 时间同步、事件同步、挑战/应答。
# 时间同步
原理是基于 动态令牌和 动态口令验证服务器的时间比对,基于 时间同步的 令牌,一般每60秒产生一个新口令,要求服务器能够十分精确的保持正确的时钟,同时对其令牌的晶振频率有严格的要求,这种技术对应的终端是硬件令牌。
# 事件同步
基于事件同步的令牌,其原理是通过某一特定的事件次序及相同的种子值作为输入,通过HASH算法中运算出一致的密码。
# 挑战/应答
常用于的网上业务,在网站/应答上输入 服务端下发的 挑战码, 动态令牌输入该挑战码,通过内置的算法上生成一个6/8位的随机数字,口令一次有效,这种技术目前应用最为普遍,包括刮刮卡、短信密码、动态令牌也有挑战/应答形式。 主流的动态令牌技术是时间同步和挑战/应答两种形式。
# OTP基本原理
计算OTP串的公式
OTP(K,C) = Truncate(HMAC-SHA-1(K,C))
K
: 表示秘钥串,这个密钥的要求是每个 HOTP 的生成器都必须是唯一的。一般我们都是通过一些随机生成种子的库来实现。C
: RFC 中把它称为移动元素(moving factor)是一个 8个 byte的数值,而且需要服务器和客户端同步。HMAC-SHA-1
: 表示使用SHA-1做HMAC;Truncate
: 是一个函数,就是怎么截取加密后的串,并取加密后串的哪些字段组成一个数字。
# HOTP原理
HOTP(HMAC-Based One Time Password) 即是基于 HMAC(基于Hash的消息认证码)实现的一次性密码。算法细节定义在RFC4226 (opens new window).
HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
一般规定 HOTP 的散列函数使用 SHA2,即:基于SHA-256
或者SHA-512
[SHA2 (opens new window)] 的散列函数做事件同步验证。
步骤文解:
使用 HMAC-SHA-1 算法基于 K 和 C 生成一个20个字节的十六进制字符串(HS)。关于如何生成这个是另外一个协议来规定的,RFC 2104 HMAC Keyed-Hashing for Message Authentication (opens new window). 实际上这里的算法并不唯一,还可以使用 HMAC-SHA-256 和 HMAC-SHA-512 生成更长的序列。对应到协议中的算法标识就是
HS = HMAC-SHA-1(K,C)
1选择这个20个字节的十六进制字符串(HS 下文使用 HS 代替 )的最后一个字节,取其低4位数并转化为十进制。比如图中的例子,第二个字节是 5a,第四位就是 a,十六进制也就是 0xa,转化为十进制就是 10。该数字我们定义为 Offset,也就是偏移量。
根据偏移量 Offset,我们从 HS 中的第 10(偏移量)个字节开始选取 4 个字节,作为我们生成 OTP 的基础数据。图中例子就是选择 50ef7f19,十六进制表示就是 0x50ef7f19,我们称为 Sbits
将上一步4个字节的十六进制字符串 Sbits 转化为十进制,然后用该十进制数对 10的Digit次幂 进行取模运算。其原理很简单根据取模运算的性质,比如 比10大的数 MOD 10 结果必然是 0到9, MOD 100 结果必然是 0-99。图中的例子,50ef7f19 转化为十进制为 1357872921,然后如果需要6位 OTP 验证码,则 1357872921 MOD 10^6 = 872921。 872921 就是我们最终生成的 OTP。
这一步可能还需要注意一点就是图中案例 Digit 不能超过10,因为即使超过10,1357872921 取模后也不会超过10位了。所以如果我们希望获取更长的验证码,需要在三步中拿到更多的十六进制字节,从而得到更大的十进制数。这个十进制决定了生成的 OTP 编码的长度。
# TOTP原理
TOTP
算法的关键在于如何更具当前时间和时间窗口计算出计数,也就是如何根据当前时间和 X 来计算 HOTP
算法中的 C。
HOTP
算法中的 C 是使用当前 Unix 时间戳 减去初始计数时间戳,然后除以时间窗口而获得的。
C = (T - T0) / X;
T
为当前时间T0
从哪个时间开始,一般取值为 0.X
表示时间步数,也就是说多长时间产生一个动态密码,这个时间间隔就是时间步数X,比如30秒;
# 代码实现
# HOTP
package com.unclezs.samples.crypto.otp;
import cn.hutool.core.codec.Base32;
import cn.hutool.core.util.StrUtil;
import lombok.SneakyThrows;
import java.lang.reflect.UndeclaredThrowableException;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.concurrent.atomic.AtomicInteger;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* HOTP 规范实现 <a href="https://datatracker.ietf.org/doc/html/rfc6238">RFC6238</a>
* <pre>
* TOTP(K,C) = HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
* </pre>
*
* @author blog.unclezs.com
* @date 2022/5/18 1:58 PM
*/
public class HOTPSample {
/**
* 用户密钥长度 80bit
*/
private static final int SECRET_KEY_BITS = 80;
/**
* TOTP 长度 : 6
*/
private static final int CODE_DIGITS = 6;
/**
* 有效视窗长度 最多计数器比本端小 10
*/
private static final int WINDOW_SIZE = 10;
/**
* 伪随机函数生成算法 sha1 (pseudorandom number generator)
*/
public static final String PRNG_ALGORITHM = "SHA1PRNG";
/**
* 加密算法 Hmac
*/
public static final String CRYPTO_ALGORITHM = "HmacSHA1";
/**
* 二维码 url 格式
**/
public static final String QRCODE_URL = "otpauth://totp/%s:%s?secret=%s&issuer=%s";
/**
* C 的 byte 数
*/
public static final int COUNTER_BITS = 8;
/**
* 模拟计数器
*/
private static final AtomicInteger COUNTER = new AtomicInteger(0);
/**
* 创建密钥
*
* @return {@link String}
*/
@SneakyThrows
public String createSecretKey() {
// 使用 SecureRandom 产生安全的随机数
SecureRandom keyRandom = SecureRandom.getInstance(PRNG_ALGORITHM);
byte[] keyBytes = keyRandom.generateSeed(SECRET_KEY_BITS / 8);
// 将随机数进行 Base32 编码,产生一个随机字符串密钥
return Base32.encode(keyBytes);
}
/**
* HmacSha1算法对窗口(C)加密
*
* @param secretKeyByte 密钥字节
* @param timeWindowBytes 窗字节
* @return {@link byte[]}
*/
private byte[] hmacSha1(byte[] secretKeyByte, byte[] timeWindowBytes) {
SecretKeySpec keySpec = new SecretKeySpec(secretKeyByte, CRYPTO_ALGORITHM);
try {
// 使用 HmacSHA1 算法,返回一个 160 bit 的 hash 值
Mac keyMac = Mac.getInstance(CRYPTO_ALGORITHM);
keyMac.init(keySpec);
return keyMac.doFinal(timeWindowBytes);
} catch (GeneralSecurityException e) {
e.printStackTrace();
throw new UndeclaredThrowableException(e);
}
}
/**
* 获得8 字节的窗口(X)
*
* @param window 窗口(X)
* @return {@link byte[]}
*/
private byte[] getCounter(int window) {
// 将 window 转为 byte 数组
byte[] counterBytes = new byte[COUNTER_BITS];
for (int i = COUNTER_BITS; i-- > 0; window >>>= 8) {
// 进行截断赋值
counterBytes[i] = (byte) window;
}
return counterBytes;
}
/**
* 获得窗口(X)
*
* @return long
*/
private int getCurrentWindow() {
return COUNTER.get();
}
/**
* 生成 TOTP
*
* @param key 秘钥 (K)
* @param c 计数器
* @return {@link String}
*/
public String generateOtp(String key, byte[] c) {
byte[] keyBytes = Base32.decode(key);
byte[] hash = hmacSha1(keyBytes, c);
// offset : 开始取字节的位置; 由于 HmacSHA1算法返回的是160bit,也就是 20 byte, 所以 hash 长度是 20, 用hash
// 的最后一位和 0xF 做 & 操作,使 0 <= offset <= 15, 这样即使 offset 为 15 ,连续 4
// 次取字节,最多取到hash[18],不会发生数组越界
int offset = hash[hash.length - 1] & 0xF;
// 从 hash 中连续取出 4 个字节(32bit),将其组成一个 int 型正整数, 进行了 4 次操作,分别是将 4个 字节移到 originOtp
// 的第 1,2,3,4 字节
// (hash[offset] & 0x7F) 则是为了 originOtp 的首位是 0, 可以得到一个正数
int originOtp =
((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | (
hash[offset + 3] & 0xFF);
// google 中 codeDigits 为 6,表示得到的 totp 长度是 6
// 对 10^6 取余,得到的余数的长度一定不大于 6
int totp = originOtp % (int) Math.pow(10, CODE_DIGITS);
// 如果得到的余数 totp 长度小于 6 ,则在前面补 0
StringBuilder resultTotp = new StringBuilder(Integer.toString(totp));
while (resultTotp.length() < CODE_DIGITS) {
resultTotp.insert(0, "0");
}
return resultTotp.toString();
}
/**
* 得到当前otp
*
* @param secretKey 秘密密钥
* @return {@link String}
*/
public String getCurrentOtp(String secretKey) {
String otp = generateOtp(secretKey, getCounter(getCurrentWindow()));
// 计数器自增
COUNTER.incrementAndGet();
return otp;
}
/**
* 检查 TOTP
*
* @param secretKey 秘密密钥
* @param targetTotp TOTP
* @return boolean
*/
public boolean checkTotp(String secretKey, String targetTotp) {
int currentWindow = getCurrentWindow();
for (int i = 0; i <= WINDOW_SIZE; i++) {
String totp = generateOtp(secretKey, getCounter(currentWindow + i));
if (StrUtil.equals(targetTotp, totp)) {
return true;
}
}
return false;
}
/**
* 生成二维码
*
* @param secretKey 秘密密钥
* @param username 用户名
* @param issuer 发行人
* @param prefix 前缀
* @return {@link String}
*/
public String generateQrCode(String secretKey, String username, String issuer, String prefix) {
return String.format(QRCODE_URL, prefix, username, secretKey, issuer);
}
public static void main(String[] args) {
HOTPSample authenticator = new HOTPSample();
String secretKey = authenticator.createSecretKey();
// secretKey = "RKJQRUEZC22LWHXK";
String otp = authenticator.getCurrentOtp(secretKey);
System.out.println(secretKey);
System.out.println(otp);
// 模拟计数器同步
COUNTER.decrementAndGet();
System.out.println(authenticator.checkTotp(secretKey, otp));
COUNTER.incrementAndGet();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
输出
2AES7T4TOH57QXUP
230078
true
2
3
# TOTP
package com.unclezs.samples.crypto.otp;
import cn.hutool.core.codec.Base32;
import cn.hutool.core.util.StrUtil;
import lombok.SneakyThrows;
import java.lang.reflect.UndeclaredThrowableException;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.concurrent.TimeUnit;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* TOTP google 规范实现 <a href="https://datatracker.ietf.org/doc/html/rfc6238">RFC6238</a>
* <pre>
* TOTP(K,C) = HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
* C = (T - T0) / X;
* T0 = 0
* </pre>
*
* @author blog.unclezs.com
* @date 2022/5/18 1:58 PM
*/
public class TOTPSample {
/**
* 用户密钥长度 80bit
*/
private static final int SECRET_KEY_BITS = 80;
/**
* TOTP 长度 : 6
*/
private static final int CODE_DIGITS = 6;
/**
* 有效视窗长度 : (-2,2), 也就是服务器时间之间 可以早 1 分钟或者晚了 1 分钟
*/
private static final int WINDOW_SIZE = 2;
/**
* TOTP更新周期 : 30s 更新一次
*/
private static final int TIME_STEP = 30;
/**
* 伪随机函数生成算法 sha1 (pseudorandom number generator)
*/
public static final String PRNG_ALGORITHM = "SHA1PRNG";
/**
* 加密算法 Hmac
*/
public static final String CRYPTO_ALGORITHM = "HmacSHA1";
/**
* 二维码 url 格式
**/
public static final String QRCODE_URL = "otpauth://totp/%s:%s?secret=%s&issuer=%s";
/**
* C 的 byte 数
*/
public static final int COUNTER_BITS = 8;
/**
* 创建密钥
*
* @return {@link String}
*/
@SneakyThrows
public String createSecretKey() {
// 使用 SecureRandom 产生安全的随机数
SecureRandom keyRandom = SecureRandom.getInstance(PRNG_ALGORITHM);
byte[] keyBytes = keyRandom.generateSeed(SECRET_KEY_BITS / 8);
// 将随机数进行 Base32 编码,产生一个随机字符串密钥
return Base32.encode(keyBytes);
}
/**
* HmacSha1算法对时间窗口(C)加密
*
* @param secretKeyByte 密钥字节
* @param timeWindowBytes 时间窗字节
* @return {@link byte[]}
*/
private byte[] hmacSha1(byte[] secretKeyByte, byte[] timeWindowBytes) {
SecretKeySpec keySpec = new SecretKeySpec(secretKeyByte, CRYPTO_ALGORITHM);
try {
// 使用 HmacSHA1 算法,返回一个 160 bit 的 hash 值
Mac keyMac = Mac.getInstance(CRYPTO_ALGORITHM);
keyMac.init(keySpec);
return keyMac.doFinal(timeWindowBytes);
} catch (GeneralSecurityException e) {
e.printStackTrace();
throw new UndeclaredThrowableException(e);
}
}
/**
* 获得8 字节的时间窗口(X)
*
* @param timeWindow 时间窗口(X)
* @return {@link byte[]}
*/
private byte[] getCounter(long timeWindow) {
// 将 timeWindow 转为 byte 数组
byte[] timeWindowBytes = new byte[COUNTER_BITS];
for (int i = COUNTER_BITS; i-- > 0; timeWindow >>>= 8) {
// 进行截断赋值
timeWindowBytes[i] = (byte) timeWindow;
}
return timeWindowBytes;
}
/**
* 获得时间窗口(X)
*
* @return long
*/
private long getCurrentWindow() {
return System.currentTimeMillis() / TimeUnit.SECONDS.toMillis(TIME_STEP);
}
/**
* 生成 TOTP
*
* @param key 秘钥 (K)
* @param c 计数器
* @return {@link String}
*/
public String generateOtp(String key, byte[] c) {
byte[] keyBytes = Base32.decode(key);
byte[] hash = hmacSha1(keyBytes, c);
// offset : 开始取字节的位置; 由于 HmacSHA1算法返回的是160bit,也就是 20 byte, 所以 hash 长度是 20, 用hash
// 的最后一位和 0xF 做 & 操作,使 0 <= offset <= 15, 这样即使 offset 为 15 ,连续 4
// 次取字节,最多取到hash[18],不会发生数组越界
int offset = hash[hash.length - 1] & 0xF;
// 从 hash 中连续取出 4 个字节(32bit),将其组成一个 int 型正整数, 进行了 4 次操作,分别是将 4个 字节移到 originOtp
// 的第 1,2,3,4 字节
// (hash[offset] & 0x7F) 则是为了 originOtp 的首位是 0, 可以得到一个正数
int originOtp =
((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | (
hash[offset + 3] & 0xFF);
// google 中 codeDigits 为 6,表示得到的 totp 长度是 6
// 对 10^6 取余,得到的余数的长度一定不大于 6
int totp = originOtp % (int) Math.pow(10, CODE_DIGITS);
// 如果得到的余数 totp 长度小于 6 ,则在前面补 0
StringBuilder resultTotp = new StringBuilder(Integer.toString(totp));
while (resultTotp.length() < CODE_DIGITS) {
resultTotp.insert(0, "0");
}
return resultTotp.toString();
}
/**
* 检查 TOTP
*
* @param secretKey 秘密密钥
* @param targetTotp TOTP
* @return boolean
*/
public boolean checkTotp(String secretKey, String targetTotp) {
long currentWindow = getCurrentWindow();
for (int i = -WINDOW_SIZE; ++i < WINDOW_SIZE; ) {
String totp = generateOtp(secretKey, getCounter(currentWindow + i));
if (StrUtil.equals(targetTotp, totp)) {
return true;
}
}
return false;
}
/**
* 生成二维码
*
* @param secretKey 秘密密钥
* @param username 用户名
* @param issuer 发行人
* @param prefix 前缀
* @return {@link String}
*/
public String generateQrCode(String secretKey, String username, String issuer, String prefix) {
return String.format(QRCODE_URL, prefix, username, secretKey, issuer);
}
public static void main(String[] args) {
TOTPSample authenticator = new TOTPSample();
String secretKey = authenticator.createSecretKey();
// secretKey = "RKJQRUEZC22LWHXK";
String totp = authenticator.generateOtp(secretKey, authenticator.getCounter(authenticator.getCurrentWindow()));
System.out.println(secretKey);
System.out.println(totp);
System.out.println(authenticator.checkTotp(secretKey, totp));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# 拓展阅读 2FA 与 2SV
# 身份认证三要素
首先解释下什么是身份认证?其实很简单,就是让对方相信你就是你。那么如何让对方相信你就是你呢?按照你能提供的信息的等级来划分,大致有如下三种信息可以证明你就是你自己:
- 你所知道的信息:比如我们最广泛使用的“用户名+密码”,因为只有你自己知道“用户名+密码”这个信息组合,那么当你把这个组合提供给我的时候,我就可以相信你就是你。
- 你所拥有的信息:假如你的“用户名+密码”泄露给了第三方,这个时候你就会有被第三方冒充的危险了。怎么办呢,再进一步提供一个只有你自己拥有的信息,即可防止被第三方冒充的危险。
- 你所独有的信息:再假设一下,你拥有的信息也被泄露给了第三方,这个时候你又会面临被冒充的危险。再进一步,提供一个只有你自己所独有的的信息,比如你的指纹,虹膜,面部特征等等。
# 2SV 两步验证(Two Steps Verification)
两步验证现在是一个再加强认证安全方面广泛使用的一个解决方案。比如Google的2SV (opens new window),Microsoft的2SV (opens new window)等等,通常的做法是当用户输入了"用户名+密码"的基础上,会让用户再提供一个一次性密码(以短信、邮件,或者动态密码生成器app的方式发放给用户)。再有比如在一些服务中需要用户额外设置的安全问题,比如“你的出生地在哪?”等等此类。
# 2FA 双因素认证(Two Factor Authentication)
2SV有个孪生兄弟2FA(双因素认证:Two Factor Authentication),那么关于2SV和2FA有什么区别呢,比如让用户在“用户名+密码”的基础上提供的额外的一次性密码,关于这个一次性密码到底是属于“你所知道的信息”还是“你所拥有的信息”呢?并没有明显的区分界限, 如果你觉得这个一次性密码属于“你所知道的信息”,那么你可以认为它是2SV;如果你觉得这个一次性密码属于“你所拥有的信息”,那么你可以认为它是2FA。
# 总结一下
2SV 的一次性密码就像是由服务商发给你的验证码,这个是需要服务端触发的,不是你随时可以看到的。
2FA 的一次性密码就是通过 OTP 算法生成的,可以在各类离线 APP 中使用,你随时可以看到的。