文章目录
- HOTP 的基础原理
- HOTP 的工作流程
- HOTP 的应用场景
- HOTP 的安全性
- 安全性增强措施
- Code
- 生成HOTP
- 可配置项
- 校验HOTP
- 可拓展功能
- 计数器(counter)
- 计数器在客户端和服务端的作用
- 计数器的同步机制
- 客户端和服务端中的计数器表现
- 服务端如何处理计数器不同步
- 计数器在客户端和服务端的举例
- 如何在 Java 实现中体现计数器
- 小结
- 一个服务端程序应对多个客户端
- 关键问题
- 解决方案
- 1. 计数器的存储和管理
- 2. 服务端管理多个客户端计数器的架构
- 3. 具体实现步骤
- 4. Java 示例代码
- 关键点解释
- 进一步优化
- 小结
- 计数器容错机制
- 验证失败常见原因
- 1. 计数器不同步
- 2. 密钥不匹配
- 3. 编码问题
- 4. 生成 OTP 时计数器没有递增
- 5. 输入 OTP 错误
- 解决方案
HOTP 的基础原理
HOTP 是基于 HMAC(Hash-based Message Authentication Code)算法的一种一次性密码生成机制。其核心思想是通过计数器的变化和共享密钥生成一次性密码。每次使用时,计数器递增,因此每个密码只能使用一次。 它遵循 RFC 4226 标准。
核心组件:
- 共享密钥(K):服务器和客户端事先约定并保存的密钥。
- 计数器(C):每生成一个一次性密码,计数器值增加,确保密码的唯一性。
- HMAC 算法:使用 HMAC-SHA-1 或其他 HMAC 哈希算法,结合共享密钥和计数器生成动态密码。
生成公式:
HOTP(K, C) = HMAC-SHA-1(K, C) mod 10^6
其中:
-
K
是共享密钥。 -
C
是计数器。 - 输出值取前 6 位数字(或更多,取决于配置),通常为 6 位数字密码。
HOTP 的工作流程
HOTP 的密码生成和验证基于计数器的增量。具体步骤如下:
-
密码生成:
- 客户端和服务器预先共享一个密钥
K
。 - 每次生成密码时,客户端使用当前计数器值
C
和密钥K
计算 HMAC 值。 - 从 HMAC 结果中截取 6 位或 8 位数字,生成一次性密码。
- 客户端和服务器预先共享一个密钥
-
密码验证:
- 服务器接收到客户端的密码后,使用相同的共享密钥
K
和当前计数器值C
生成 HMAC 值。 - 如果生成的 HMAC 值与客户端提供的密码匹配,认证成功,服务器递增计数器
C
。 - 服务器通常允许一定的容错窗口(如 ±2 个计数器值),以防止由于计数器不同步导致的验证失败。
- 服务器接收到客户端的密码后,使用相同的共享密钥
HOTP 的应用场景
HOTP 广泛应用于需要基于事件或计数器的系统中,典型场景包括:
- 硬件令牌:银行、企业安全系统等早期使用的物理设备,用户通过令牌生成动态密码。
- 基于事件的身份验证系统:每当发生某些特定事件(如用户发起登录请求或支付请求)时,系统使用 HOTP 生成密码。
- 离线环境:由于 HOTP 不依赖时间,因此在网络连接不稳定或设备无法持续联网的场景下尤为适用。
HOTP 的安全性
优势:
- 基于事件驱动:HOTP 的密码生成依赖于计数器,用户可以在不依赖时间同步的情况下生成一次性密码,适用于网络不稳定或离线操作场景。
- 兼容性强:HOTP 算法简单,易于实现,且支持广泛的设备和系统。
- 无时间同步问题:由于它基于计数器而非时间,客户端和服务器之间无需保持时间同步。
潜在问题:
- 密码有效期较长:HOTP 密码在未使用前一直有效,因此可能被攻击者截取后重放。这一点使得它在安全性方面较 TOTP 弱。
- 计数器同步问题:客户端和服务器的计数器需要同步。如果用户多次生成密码但没有使用,则可能导致计数器不同步。为此,服务器通常允许一个容错窗口,但这也可能被攻击者利用来猜测计数器的值。
- 有限容错窗口可能被滥用:服务器在验证时允许的容错窗口可能导致暴力破解攻击,即攻击者尝试多个计数器值,直到找到有效的密码。
安全性增强措施
- 设置较短的密码有效期:在服务端设置较短的密码有效期,确保未使用的 HOTP 密码快速失效。
- 配合其他身份验证手段:与二次身份验证或生物识别等方法结合使用,防止密码被重放攻击或暴力破解。
- 动态调整容错窗口:服务器可以根据用户行为动态调整容错窗口的大小,以减少密码被暴力破解的风险。
Code
生成HOTP
基于 HMAC-SHA-256 算法生成一次性密码(OTP)。
我们使用了 javax.crypto
包中的 HMAC 相关类来实现 HMAC-SHA-356 算法,并生成 6 位的 OTP。
package com.artisan.otp.hotp;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
/**
* @author 小工匠
* @version 1.0
* @date 2024/10/1 21:22
* @mark: show me the code , change the world
*/
public class HOTP {
// 生成HOTP码
public static String generateHOTP(String key, long counter, int digits) throws Exception {
// 将key转换为字节数组
byte[] keyBytes = hexStr2Bytes(key);
// 计算HMAC-SHA-1
byte[] counterBytes = longToBytes(counter);
byte[] hmacResult = hmacSHA1(keyBytes, counterBytes);
// 获取动态截取码(Dynamic Truncation)
int otp = dynamicTruncate(hmacResult) % (int) Math.pow(10, digits);
// 格式化OTP为固定长度,不足位数用0填充
return String.format("%0" + digits + "d", otp);
}
// 使用HmacSHA256生成hash
private static byte[] hmacSHA1(byte[] key, byte[] counter) throws Exception {
// HmacSHA1 HmacSHA256
SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(signKey);
return mac.doFinal(counter);
}
// 动态截取(Dynamic Truncation)
private static int dynamicTruncate(byte[] hmacResult) {
// 取HMAC结果的最后字节的低4位作为偏移量
int offset = hmacResult[hmacResult.length - 1] & 0xf;
// 从偏移位置起,取4个字节组成一个31位的整数
return ((hmacResult[offset] & 0x7f) << 24) |
((hmacResult[offset + 1] & 0xff) << 16) |
((hmacResult[offset + 2] & 0xff) << 8) |
(hmacResult[offset + 3] & 0xff);
}
// 将long类型的计数器转换为字节数组(8字节)
private static byte[] longToBytes(long value) {
return BigInteger.valueOf(value).toByteArray();
}
// 将十六进制字符串转换为字节数组
private static byte[] hexStr2Bytes(String hex) {
byte[] bytes = new byte[hex.length() / 2];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16);
}
return bytes;
}
public static void main(String[] args) {
try {
// 测试HOTP生成
String secret = "3132333435363738393031323334353637383930"; // 十六进制密钥
long counter = 1; // 计数器
int digits = 6; // OTP长度
String hotp = generateHOTP(secret, counter, digits);
System.out.println("Generated HOTP: " + hotp);
} catch (Exception e) {
e.printStackTrace();
}
}
}
代码解释
-
密钥 (
key
):密钥为一个十六进制字符串表示的共享密钥,转换为字节数组用于生成 HMAC。 -
计数器 (
counter
):每次生成新的 OTP 时,计数器递增。这是 HOTP 的核心机制。 -
HMAC-SHA256:使用
javax.crypto.Mac
类生成 HMAC-SHA256哈希值。 -
动态截取 (
dynamicTruncate
):HOTP 的最后步骤,根据哈希结果的偏移量提取 31 位的数值,然后对其取模来生成 6 位的一次性密码。
运行结果
在 main
方法中,使用一个示例密钥和计数器来生成 6 位的 HOTP。例如,如果你使用的计数器为 1,生成的 HOTP 可能是 638487
。
可配置项
- 密钥长度:可以使用更长的密钥,建议密钥使用至少 160 位或 256 位的密钥(如 SHA-256 或更高)。
-
OTP 位数:
digits
参数允许生成 6 位、7 位或 8 位等不同长度的 OTP。
校验HOTP
为了验证 HOTP,我们需要客户端生成的 OTP 与服务器端生成的 OTP 一致。由于 HOTP 依赖于共享密钥和计数器,所以我们要确保在客户端和服务器端使用相同的密钥和计数器值。
验证 HOTP 的 Java 实现
在下面的实现中,服务器会接受用户输入的 OTP,并与使用相同计数器生成的 OTP 进行比较。如果 OTP 匹配,则验证成功。
package com.artisan.otp.hotp;
import java.util.Scanner;
public class HOTPVerifier2 {
// 验证HOTP
public static boolean verifyHOTP(String key, long counter, String inputOTP, int digits) throws Exception {
// 生成服务器端的HOTP
String serverHOTP = HOTP.generateHOTP(key, counter, digits);
// 比较用户输入的OTP和服务器生成的HOTP
return serverHOTP.equals(inputOTP);
}
public static void main(String[] args) {
try {
// 设置密钥和计数器
String secret = "3132333435363738393031323334353637383930"; // 与客户端一致的十六进制密钥
long counter = 1; // 当前计数器值
int digits = 6; // OTP长度
Scanner scanner = new Scanner(System.in);
String inputOTP;
boolean isValid;
// 允许用户多次输入OTP进行验证
while (true) {
// 生成服务器端的HOTP
String expectedHOTP = HOTP.generateHOTP(secret, counter, digits);
System.out.println("服务器生成的 HOTP: " + expectedHOTP);
// 提示用户输入OTP
System.out.print("请输入 OTP(或输入 'exit' 退出):");
inputOTP = scanner.nextLine();
// 如果用户输入 'exit' 则退出程序
if (inputOTP.equalsIgnoreCase("exit")) {
System.out.println("退出验证程序。");
break;
}
// 验证用户输入的OTP是否正确
isValid = verifyHOTP(secret, counter, inputOTP, digits);
if (isValid) {
System.out.println("验证成功,OTP正确!");
// 验证成功后增加计数器
counter++;
} else {
System.out.println("验证失败,OTP不正确!");
}
System.out.println();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
-
verifyHOTP
:该方法用于验证客户端生成的 OTP 是否与服务器生成的 OTP 相同。它会调用HOTP.generateHOTP
方法生成服务器端的 OTP,然后将其与用户输入的 OTP 进行比较。 -
密钥 (
key
):服务器和客户端必须使用相同的密钥。此密钥在初始化时由双方共享。 -
计数器 (
counter
):计数器确保每次生成的 OTP 都是唯一的。每验证一次 OTP,计数器需要递增。 -
OTP 位数 (
digits
):定义 OTP 的位数,如 6 位、7 位或 8 位。
验证流程
- 生成服务器端的 OTP:服务器根据密钥和计数器生成一个 OTP。
- 用户输入 OTP:用户在客户端生成 OTP 后,在服务器端输入以进行验证。
- 服务器验证 OTP:服务器通过比较自己生成的 OTP 和用户输入的 OTP,决定是否验证成功。
服务器和客户端使用相同的计数器值和密钥,所以 OTP 匹配。如果输入的 OTP 错误,服务器会显示验证失败。
可拓展功能
- 计数器同步:在实际应用中,计数器同步可能出现问题。你可以实现一个容错机制,允许服务器在一定范围内(例如,±2个计数器)接受 OTP 。
- 安全提示:为了提升安全性,可以结合其他验证方式,比如 IP 白名单、二次验证或时间限制等。
计数器(counter)
在 HOTP(HMAC-based One Time Password)机制中,计数器(counter) 是保证 OTP(一次性密码)唯一性和安全性的重要组成部分。它在客户端和服务端都存在,并且双方必须保持同步。
计数器在客户端和服务端的作用
-
客户端:每次用户请求生成 OTP 时,客户端都会使用当前的计数器值与共享密钥,通过 HMAC-SHA-256 算法生成一次性密码(OTP)。客户端自己维护计数器,每生成一次 OTP,计数器可以递增。
-
服务端:当服务端验证用户输入的 OTP 时,它也使用同样的密钥和计数器生成相同的 OTP。为了验证成功,服务端的计数器必须和客户端的计数器同步或相差在一个容忍窗口内(如±2)。每次验证成功后,服务端也需要递增计数器以准备下一次验证。
计数器的同步机制
-
初始同步:客户端和服务端通常从一个初始值开始,比如 0 或 1。初始值在客户端和服务端协商时确定。由于计数器是由双方共享的,客户端和服务端在初次使用时保持一致。
-
递增:每次生成 OTP 后,客户端和服务端的计数器都会递增。每当客户端生成一个 OTP 并输入,服务器验证后也递增计数器。这样,保证双方的计数器同步,并确保下一个 OTP 的生成不会重复。
客户端和服务端中的计数器表现
-
客户端计数器
- 每当用户请求生成 OTP 时,客户端会读取当前计数器值,生成 OTP。
- 在用户提交 OTP 后,客户端可以选择递增计数器以生成下一个 OTP。
- 如果客户端的计数器与服务端的计数器同步,它们生成的 OTP 是相同的。
-
服务端计数器
- 服务端在接收到用户提交的 OTP 时,会使用自己维护的计数器值生成 OTP,并将其与用户的 OTP 进行比较。
- 如果 OTP 匹配,服务端验证成功,并递增计数器。
- 如果 OTP 不匹配,服务端可以尝试在一定的计数器范围内(如 ±1 或 ±2)进行匹配,以防止客户端和服务端的计数器不同步。
服务端如何处理计数器不同步
由于某些原因(如客户端多次生成 OTP 而没有使用,网络延迟等),客户端和服务端的计数器可能会不同步。服务端可以通过以下策略处理这种情况:
-
容错窗口(window):服务端在验证 OTP 时,可以尝试在当前计数器值的基础上,向前或向后偏移几个计数器值。例如,服务端可以尝试使用
counter ± 1
或counter ± 2
的值进行 OTP 验证。这种方式允许计数器有一个容错范围,避免客户端和服务端不同步导致验证失败。 -
重同步机制:某些系统中,会通过一个重新同步流程来重新对齐客户端和服务端的计数器。例如,如果检测到计数器不同步,可以让客户端和服务端通过一个安全通道重新协商新的计数器值。
-
计数器更新:服务端在验证成功后,会更新自己的计数器值,使其与客户端保持同步。
计数器在客户端和服务端的举例
-
客户端生成 OTP
- 假设当前客户端的计数器为 5,密钥为
"secret"
, 生成 OTP 为123456
。 - 客户端显示 OTP 并递增计数器,计数器更新为 6。
- 假设当前客户端的计数器为 5,密钥为
-
服务端验证 OTP
- 服务端接收到 OTP
123456
,当前计数器也是 5(与客户端同步)。 - 服务端生成 OTP
123456
并验证成功,然后将计数器更新为 6。
- 服务端接收到 OTP
-
客户端下一次生成 OTP
- 客户端使用计数器值 6 生成新的 OTP(例如,
654321
),提交给服务端。 - 服务端的计数器也为 6,生成相同的 OTP 并验证成功,继续递增计数器。
- 客户端使用计数器值 6 生成新的 OTP(例如,
如何在 Java 实现中体现计数器
在 Java 实现中,计数器可以作为一个持久化的变量,客户端和服务端都需要维护各自的计数器值。以下是计数器的处理流程:
// 假设客户端生成OTP时计数器值为5
long clientCounter = 5;
String clientHOTP = HOTP.generateHOTP(secret, clientCounter, digits);
System.out.println("客户端生成的 OTP: " + clientHOTP);
// 假设服务端当前计数器值为5
long serverCounter = 5;
boolean isValid = HOTPVerifier.verifyHOTP(secret, serverCounter, clientHOTP, digits);
if (isValid) {
System.out.println("验证成功,计数器同步!");
// 递增服务端的计数器
serverCounter++;
} else {
System.out.println("验证失败,可能计数器不同步。");
}
小结
- 客户端和服务端的计数器同步 是 HOTP 正常工作的核心。
- 容错机制 允许一定范围内的计数器不同步,以提升用户体验。
- 计数器持久化 是关键:客户端和服务端在每次生成或验证后都要更新并保存计数器的值。
一个服务端程序应对多个客户端
当一个服务端程序需要处理多个客户端的 HOTP 验证时,计数器(counter) 的管理变得更加复杂,因为每个客户端都会有自己的计数器,并且需要和服务端保持同步。为了确保 OTP 的唯一性和正确性,服务端必须为每个客户端维护独立的计数器,并正确更新计数器的状态。
关键问题
- 每个客户端都有独立的计数器:每个客户端的计数器必须单独管理,因为每个客户端的 OTP 会根据各自的计数器生成。
- 持久化计数器:服务端需要确保计数器在每次 OTP 验证后持久化,以避免计数器不同步的风险。如果服务重启或在不同会话间,需要从持久化存储中加载计数器。
- 并发控制:当多个客户端同时发起 OTP 请求时,服务端需要确保对同一个客户端的计数器读写操作是线程安全的,以防计数器状态被并发修改。
解决方案
1. 计数器的存储和管理
服务端可以为每个客户端使用独立的存储(如数据库、内存缓存等)来持久化和管理计数器。通常,可以通过客户端的唯一标识符(如用户 ID、设备 ID 等)来关联计数器。
-
存储方式:
- 数据库:可以使用关系型数据库(如 MySQL、PostgreSQL)或 NoSQL 数据库(如 Redis、MongoDB)来存储每个客户端的计数器。
- 内存缓存:在高性能环境中,使用内存缓存(如 Redis)存储计数器,以便快速访问和更新。
2. 服务端管理多个客户端计数器的架构
服务端需要为每个客户端分配唯一的计数器。以下是服务端如何处理多个客户端的示例架构:
-
客户端身份识别:服务端需要使用某种方式来识别每个客户端(例如,用户 ID 或设备 ID)。这样可以保证每个客户端有唯一的计数器。
-
计数器存储:每个客户端的计数器可以存储在数据库或缓存中,按客户端唯一 ID 来索引。
-
计数器读写的同步处理:在多线程或并发请求的场景下,确保对计数器的读写是线程安全的。可以使用锁机制来确保同一时刻只有一个请求在修改某个客户端的计数器。
3. 具体实现步骤
-
生成 OTP:
- 当客户端请求生成 OTP 时,服务端从数据库或缓存中读取该客户端的计数器,生成 OTP,并将计数器递增。
-
验证 OTP:
- 当客户端发送 OTP 给服务端验证时,服务端读取该客户端的计数器,并用相同的密钥生成 OTP。
- 服务端还可以允许一定范围的容错窗口(如
counter ± 2
)来应对客户端和服务端的计数器不同步问题。 - 验证成功后,更新计数器并持久化。
4. Java 示例代码
以下是处理多个客户端的伪代码示例,展示了如何为每个客户端维护独立的计数器。
package com.artisan.otp.hotp;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class HOTPServer {
// 使用ConcurrentHashMap存储每个客户端的计数器,key为客户端ID,value为计数器值
private ConcurrentHashMap<String, Long> clientCounters = new ConcurrentHashMap<>();
// 使用一个锁来保证对每个客户端计数器的线程安全访问
private Lock lock = new ReentrantLock();
// 模拟数据库存储的客户端密钥
private ConcurrentHashMap<String, String> clientSecrets = new ConcurrentHashMap<>();
public HOTPServer() {
// 初始化假设有两个客户端,每个客户端都有一个共享密钥
clientSecrets.put("client1", "3132333435363738393031323334353637383930");
clientSecrets.put("client2", "3132333435363738393031323334353637383931");
// 初始化计数器
clientCounters.put("client1", 1L);
clientCounters.put("client2", 1L);
}
// 生成OTP的函数,传入客户端ID
public String generateOTP(String clientId) throws Exception {
lock.lock(); // 锁定以确保计数器的安全访问
try {
// 获取客户端的计数器和密钥
Long counter = clientCounters.get(clientId);
String secret = clientSecrets.get(clientId);
if (counter == null || secret == null) {
throw new Exception("客户端未注册");
}
// 生成OTP
String otp = HOTP.generateHOTP(secret, counter, 6);
// 递增计数器并更新
clientCounters.put(clientId, counter + 1);
return otp;
} finally {
lock.unlock(); // 解锁
}
}
// 验证OTP的函数
public boolean verifyOTP(String clientId, String inputOTP) throws Exception {
lock.lock();
try {
// 获取客户端的计数器和密钥
Long counter = clientCounters.get(clientId);
String secret = clientSecrets.get(clientId);
if (counter == null || secret == null) {
throw new Exception("客户端未注册");
}
// 生成服务器端的OTP
String serverOTP = HOTP.generateHOTP(secret, counter, 6);
// 验证客户端输入的OTP
if (serverOTP.equals(inputOTP)) {
// 验证成功后更新计数器
clientCounters.put(clientId, counter + 1);
return true;
} else {
return false;
}
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
try {
HOTPServer server = new HOTPServer();
String clientId = "client1";
// 服务端生成OTP