概叙
科普文:软件架构设计之应用安全【身份验证(Authentication):短信验证、图片验证、TOTP一次性密码、2FA双重身份验证等小结】-CSDN博客
科普文:软件架构设计之应用安全【身份验证(Authentication):OTP一次性密码 TOTP和HOTP小结】-CSDN博客
一、OTP历史溯源
动态口令(OTP)有一个同名确不同翻译的前辈,一次性密码(OTP, One-Time Pad),也叫密电本,是一种应用于军事领域的谍报技术,即对通信信息使用预先约定的一次性密电本进行加密和解密,使用后的密电本部分丢弃不再使用,能够做到一次一密。
如果看过一些国内的谍战电视剧可能会对在二战时期,日本轰炸重庆中的一个号称“独臂大盗”的日本间谍有印象的话,他同日军通电使用的就是一次性密码技术,使用诺贝尔获奖的小说《The Good Earth》进行谍报编码,最后是被称为美国密码之父的赫伯特·亚德利破获。
而目前在安全强认证领域使用的OTP动态密码技术,源于最早由RSA公司于1986年开发的RSA SecureID产品,动态密码并不是一次性密码技术,而是动态一次性口令技术。
目前,国际上动态口令OTP有2大主流算法,一个是RSA SecurID ,一个是OATH组织的OTP算法。如果在国内来说的话,另一个是国密的OTP密码算法。RSA SecurID使用AES对称算法,OATH使用HMAC算法,国密算法使用的国密SM1(对称)和SM3(HASH)算法。
二、OTP认证原理与同步方法
动态口令的基本认证原理是在认证双方共享密钥,也称种子密钥,并使用的同一个种子密钥对某一个事件计数、或时间值、或者是异步挑战数进行密码算法计算,使用的算法有对称算法、HASH、HMAC,之后比较计算值是否一致进行认证。
可以做到一次一个动态口令,使用后作废,口令长度通常为6-8个数字,使用方便,与通常的静态口令认证方式类似,使用方便与系统集成好,因此OTP动态口令技术的应用非常普遍,可以应用于多种系统渠道使用,如:Web应用、手机应用、电话应用、ATM自助终端等。
动态口令的同步机制有3种,即时间型、事件型和挑战与应答型,目前应用最多的是时间型动态口令,挑战与应答型动态口令的应用也逐渐增多,并且动态口令逐渐变为多种同步类型复合的机制发展,如时间+挑战与应答型。
三、OTP与常用认证技术比较
目前在信息系统中使用的增强型认证技术包括:
- 1 USBKey: 申请PKI证书。
- 2 动态口令卡:打印好的密码刮刮卡。
- 3 动态短信:使用电信通道下发口令。
- 4 IC卡/SIM卡:内置与用户身份相关的信息。
- 5 生物特征:采用独一无二的生物特征来验证身份,如指纹。
- 6 动态令牌:动态口令生成器和认证系统。
四、OTP选择建议
对比维度 | TOTP | HOTP |
---|---|---|
生成方式 | 基于时间窗口(每 30 秒生成一个密码) | 基于计数器递增(每次计数器增加生成一个密码) |
时间依赖性 | 高:需要设备和服务器时间同步 | 低:不依赖时间同步 |
计数器管理 | 无:时间窗口自动递增 | 需要:设备和服务器需要维护一致的计数器 |
适用场景 | 移动应用、实时验证(如 Google Authenticator) | 硬件令牌、离线验证(如银行 U 盾) |
安全性 | 高:密码短暂有效,且基于时间窗口 | 高:密码唯一且短暂有效,但依赖计数器同步 |
用户体验 | 好:无需手动输入密钥,设备本地生成密码 | 较差:可能需要手动输入密码或处理计数器同步 |
复杂性 | 低:设备和服务器只需同步时间 | 高:需要管理计数器,同步逻辑复杂 |
一次性密码(OTP,One-Time Password)是一种用于身份验证的安全机制,通常用于提高用户账户的安全性。下面我将一步一步地分析一下OTP的工作原理。
- 生成:OTP通常在用户尝试登录时生成。它可以通过多种方式生成,比如时间同步(TOTP,基于时间的一次性密码)或事件驱动(HOTP,基于计数的一次性密码)。
- 发送:生成的OTP会通过安全的方式发送给用户,例如通过短信、电子邮件或专用的认证应用程序(如Google Authenticator)。
- 验证:用户输入收到的OTP,系统会对比输入的密码与生成的密码,进行验证。
- 有效性:OTP一般有时间限制,比如有效期为30秒,过期后需要重新生成。
这种机制能有效防止重放攻击和钓鱼攻击,因为每个密码只使用一次且是短暂有效的。
- TOTP 更适合移动应用和实时验证场景。
- HOTP 更适合硬件令牌和离线验证场景。
- 两者都是高安全性的 OTP 实现方式,但适用场景和管理复杂性不同。
OTP选择建议
- 选择 TOTP:如果你的应用需要实时验证且设备支持时间同步(如移动应用),TOTP 是更好的选择。
- 选择 HOTP:如果你的应用需要离线验证或硬件令牌支持(如银行 U 盾),HOTP 是更好的选择。
基于哈希的动态密码HOTP详解
前面有对OTP的两种方式TOTP、HOTP做了梳理,这里我们详细看看HOTP的详细过程。
参考协议文档:RFC 4226: HOTP: An HMAC-Based One-Time Password Algorithm
对于 HOTP,我们已经看到输入算法的主要有两个元素,一个是共享密钥,另外一个是计数。
在 RFC 算法中用一下字母表示:
- K 共享密钥,这个密钥的要求是每个 HOTP 的生成器都必须是唯一的。一般我们都是通过一些随机生成种子的库来实现。
- C 计数器,RFC 中把它称为移动元素(moving factor)是一个 8个 byte的数值,而且需要服务器和客户端同步。
另外一个参数比较好理解, - Digit 表示产生的验证码的位数,最后两个参数可能暂时不好理解,我们先放在这,等用到在解释
- T 称为限制参数(Throttling Parameter)表示当用户尝试 T 次 OTP 授权后不成功将拒绝该用户的连接。
- s 称为重新同步参数(Resynchronization Parameter)表示服务器将通过累加计数器,来尝试多次验证输入的一次性密码,而这个尝试的次数及为
s。该参数主要是有效的容忍用户在客户端无意中生成了额外不用于验证的验证码,导致客户端和服务端不一致,但同时也限制了用户无限制的生成不用于验证的一次性密码。
HOTP算法描述
HOTP 出现的比较早,是基于 HMAC 实现的,其实 HMAC 就是 Hash + 消息或者说是 Hash + 盐(salt)来实现的,其中 Hash 函数通常采用 MD5、SHA 系列(SHA1/SHA256/SHA512 等)的单向消息摘要算法来实现。
在 HOTP 中,Hash 函数采用 SHA1,在消息或者盐值部分放的是一个计数器,也就是一个大小为 8 字节的数字,主要的生成公式如下:
其中,key 表示对消息加密的密钥,counter 就是计数器的值。
然后两者通过基于 SHA1 的 HMAC 算法计算出结果,所以结果长度是 20 字节,如果用 16 进制表示长度就是 40。由于结果太长了显然不利于用户输入,因此通过 Truncate 函数进行处理,处理成 6-8 位的数字,就和短信验证码一样,这样用户就可以很快的输入并进行验证。
HOTP生成步骤
详细步骤如下:
核心步骤主要是使用 K C 和 Digit。
-
第一步:使用 HMAC-SHA-1 算法基于 K 和 C 生成一个20个字节的十六进制字符串(HS)。关于如何生成这个是另外一个协议来规定的,RFC2104 HMAC Keyed-Hashing for Message Authentication. 实际上这里的算法并不唯一,还可以使用 HMAC-SHA-256 和 HMAC-SHA-512生成更长的序列。对应到协议中的算法标识就是HS = HMAC-SHA-1(K,C)
-
第二步:选择这个20个字节的十六进制字符串(HS 下文使用 HS 代替 )的最后一个字节,取其低4位数并转化为十进制。比如图中的例子,最后的字节是5a,第四位就是 a,十六进制也就是 0xa,转化为十进制就是 10。该数字我们定义为 Offset,也就是偏移量。
-
第三步:根据偏移量 Offset,我们从 HS 中的第 10(偏移量)个字节开始选取 4 个字节,作为我们生成 OTP 的基础数据。图中例子就是选择50ef7f19,十六进制表示就是 0x50ef7f19,我们成为 Sbits以上两步在协议中的成为 Dynamic Truncation (DT)算法,具体参考以下伪代码:
Let Sbits = DT(HS) // DT, defined below,
// returns a 31-bit string
展开就是
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
- 第四步:将上一步4个字节的十六进制字符串 Sbits 转化为十进制,然后用该十进制数对 10的Digit次幂 进行取模运算。其原理很简单根据取模运算的性质,比如比10大的数 MOD 10 结果必然是 0到9, MOD 100 结果必然是 0-99。图中的例子,50ef7f19 转化为十进制为 1357872921,然后如果需要6位OTP 验证码,则 1357872921 MOD 10^6 = 872921。 872921 就是我们最终生成的 OTP。
对应到协议中的算法为:
Let Snum = StToNum(Sbits) // Convert S to a number in
// 0...2^{31}-1
Return D = Snum mod 10^Digit // D is a number in the range
// 0...10^{Digit}-1
这一步可能还需要注意一点就是图中案例 Digit 不能超过10,因为即使超过10,1357872921 取模后也不会超过10位了。所以如果我们希望获取更长的验证码,需要在三步中拿到更多的十六进制字节,从而得到更大的十进制数。这个十进制决定了生成的OTP 编码的长度。
HOTP生成示例
在这里HMAC-SHA-1刚好生成20字节的字节数组。
HOTP的java实现
定义通用接口
public interface OTPService {
int generateOneTimePassword(Key key, long counter) throws Exception;
boolean verifyHOTP(Key key, long counter, int inputOTP) throws Exception;
}
HOTP的java实现
* 使用 HOTP 算法根据密码和计数器生成一次性密码。 * 1.使用密码生成密钥; * 2.将 counter 转换为 8 字节表示; * 3.更新 MAC 输入数据; * 4.执行 MAC 计算并将结果写入缓冲区; * 5.动态截取部分字节并组合成整数; * 6.取模生成 6 位数字 OTP。
public class HOTPService implements OTPService {
final String algorithm;
final Mac mac;
final byte[] buffer;
final int macLength;
final int modDivisor;
final int HOTP_Length;
/**
* 密码失效机制:
* 一个动态密码的生成,取决于共享密钥以及移动因子的值,而共享密钥是保持不变的,最终就只有移动因子决定了密码的生成结果。
* 所以在 HOTP 算法中,要求每次密码验证成功后,认证服务器端以及密码生成器(客户端)都要将计数器的值加1,已确保得到新的密码。
*
* */
LongAdder counter=new LongAdder();
public HOTPService() throws NoSuchAlgorithmException {
algorithm = "HmacSHA256";
mac = Mac.getInstance(algorithm);
this.macLength = mac.getMacLength();
buffer = new byte[macLength];
HOTP_Length = 6;
modDivisor = (int) Math.pow(10, HOTP_Length); // 取模生成6位数字
}
protected Key generateKey(String password) throws UnsupportedEncodingException, InvalidKeyException {
Key key = new SecretKeySpec(password.getBytes("UTF-8"), algorithm);
// 创建MAC对象并初始化
mac.init(key);
return key;
}
public int generateOneTimePassword(String password) throws Exception {
long counter=this.counter.longValue();
int hotpNumber = generateOneTimePassword(generateKey(password), counter);
this.counter.increment();
return hotpNumber;
}
/**
* 使用 HOTP 算法根据密码和计数器生成一次性密码。
* 1.使用密码生成密钥;
* 2.将 counter 转换为 8 字节表示;
* 3.更新 MAC 输入数据;
* 4.执行 MAC 计算并将结果写入缓冲区;
* 5.动态截取部分字节并组合成整数;
* 6.取模生成 6 位数字 OTP。
*
* @param key 密码字符串生成的key
* @param counter 当前计数器值,用于确保每次生成不同密码
* @return int 生成的六位数字 OTP
* @throws Exception 如果发生异常,如 MAC 初始化失败或缓冲区不足
*/
@Override
public int generateOneTimePassword(Key key, long counter) throws Exception {
// 1.使用密码生成密钥;
//this.mac.init(key);
// 2.将 counter 转换为 8 字节表示;
this.buffer[0] = (byte) ((counter & 0xff00000000000000L) >>> 56);
this.buffer[1] = (byte) ((counter & 0x00ff000000000000L) >>> 48);
this.buffer[2] = (byte) ((counter & 0x0000ff0000000000L) >>> 40);
this.buffer[3] = (byte) ((counter & 0x000000ff00000000L) >>> 32);
this.buffer[4] = (byte) ((counter & 0x00000000ff000000L) >>> 24);
this.buffer[5] = (byte) ((counter & 0x0000000000ff0000L) >>> 16);
this.buffer[6] = (byte) ((counter & 0x000000000000ff00L) >>> 8);
this.buffer[7] = (byte) (counter & 0x00000000000000ffL);
// 3.更新 MAC 输入数据;
this.mac.update(this.buffer, 0, 8);
try {
// 4.执行 MAC 计算并将结果写入缓冲区;
this.mac.doFinal(this.buffer, 0);
} catch (final ShortBufferException e) {
// We allocated the buffer to (at least) match the size of the MAC length at
// construction time, so this
// should never happen.
throw new RuntimeException(e);
}
// 5.动态截取部分字节并组合成整数;
// 6.取模生成 6 位数字 OTP。
final int offset = this.buffer[this.buffer.length - 1] & 0x0f;
return (
(this.buffer[offset] & 0x7f) << 24
| (this.buffer[offset + 1] & 0xff) << 16
| (this.buffer[offset + 2] & 0xff) << 8
| (this.buffer[offset + 3] & 0xff)
) % this.modDivisor;
}
@Override
public boolean verifyHOTP(Key key, long counter, int inputOTP) throws Exception {
int hotpNumber = generateOneTimePassword(key, counter);
//String hotpString = String.format("%0" + HOTP_Length + "d", hotpNumber);
return hotpNumber==inputOTP;
}
}
效果
这里缺少生成动态密码的二维码功能,以及动态密码二维码扫码功能,这些功能,感兴趣的可以自行补充完善。
还有就是这里countor计数器,每个用户都需要独立记载自己的计数器。如下一个用户实体类User
,用于存储用户信息,包括TOTP和HOTP的密钥。
import jakarta.persistence.*;
import lombok.Data;
@Data
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(name = "totp_secret")
private String totpSecret;
@Column(name = "hotp_secret")
private String hotpSecret;
@Column(name = "hotp_counter")
private int hotpCounter;
}