TOTP动态令牌原理简述
TOTP 是Time-based One-Time Password的简写,表示基于时间戳算法的一次性密码。在相同时间内生成的口令都是一样的,一般每60秒或者30秒产生一个新口令;因此要求客户端和服务端必须保证精确的时钟,这样客户端和服务端基于时间生成的动态口令才能一致。
通常用于登陆时的动态密码验证、银行转账动态密码等基于时间有效性验证的应用场景。
注意:这种动态令牌并不能保证安全,因为客户端是独立生成动态口令的,因此一旦客户端代码泄漏,那么攻击者就可以自己生成动态令牌了;此时服务端是无法知晓令牌被破解,所有应当随机或定时更换密钥等信息。
其计算公式为:
TOTP(K, TC) = Truncate(HMAC-SHA-1(K, TC))
- K: HMAC-SHA-1加密的密钥串,表示使用SHA-1做HMAC(当然也可以使用SHA-256等);
- TC:基于时间戳计算得出;简单来说就是先计算出时间间隔,再计算经过了多个周期(也就是我们设置的令牌有效期的倍数);公式为
TC = (T - T0) / T1
, 其中T为当前时间,T0为起始时间(通常为0),T1位令牌有效时间。 - Truncate:是一个函数,用于截取加密后的字符串。
Truncate函数:
- 取加密后的最后一个字节的的低4位,得到offset;
- 取加密后的字节数组下标为offset、offset+1、offset+2、offset+3的4个字节通过移位操作让其刚好是int最大位数32,binary;
- 根据需要的长度对binary取模, opt;
- 以字符串方式返回opt,如果opt长度不够再补足长度;
算法实现
public class TOTP {
/**
* 共享密钥
*/
private final String secretKey;
/**
* 时间步长 单位:毫秒 作为口令变化的时间周期
*/
private final long step;
/**
* 转码位数 [1-8]
*/
private final int codeDigits;
/**
* 初始化时间
*/
private static final long INITIAL_TIME = 0;
/**
* 柔性时间回溯,单位毫秒
*/
private final long flexibilityTime;
/**
* 数子量级;这个用于后面计算令牌的长度
*/
private static final int[] DIGITS_POWER = {1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000};
private TOTP(String secretKey, long step, long flexibilityTime, int codeDigits) {
this.secretKey = secretKey;
this.step = step;
this.flexibilityTime = flexibilityTime;
this.codeDigits = codeDigits;
}
public static TotpBuilder builder() {
return new TOTP.TotpBuilder();
}
static class TotpBuilder {
/**
* 共享密钥
*/
private String secretKey;
/**
* 时间步长 单位:毫秒 作为口令变化的时间周期
*/
private long step = 2 * 60 * 1000;
/**
* 柔性时间回溯,单位毫秒
*/
private long flexibilityTime = 30 * 1000;
/**
* 转码位数 [1-8]
*/
private int codeDigits = 8;
public TotpBuilder secretKey(String secretKey){
this.secretKey = secretKey;
return this;
}
public TotpBuilder step(long step){
this.step = step;
return this;
}
public TotpBuilder flexibilityTime(long flexibilityTime){
this.flexibilityTime = flexibilityTime;
return this;
}
public TotpBuilder codeDigits(int codeDigits){
if(codeDigits<0 || codeDigits>8){
throw new RuntimeException("the codeDigits only super 0~8.");
}
this.codeDigits = codeDigits;
return this;
}
public TOTP build(){
return new TOTP(this.secretKey, this.step, this.flexibilityTime, this.codeDigits);
}
}
/**
* 生成一次性密码
*
* @param code 共享密码
* @return String
*/
public String generateTOTP(String code) {
return generateTOTP(timestamp(), code, false);
}
/**
* 生成一次性密码
*
* @param code 共享密码
* @param flexibility 是否是生成柔性令牌
* @return String
*/
private String generateTOTP(long now, String code, boolean flexibility) {
String tc = null;
if (flexibility) {
tc = Long.toHexString(timeFactor(now - this.flexibilityTime)).toUpperCase();
} else {
tc = Long.toHexString(timeFactor(now)).toUpperCase();
}
return generateTOTPHmacSHA256(code + this.secretKey, tc);
}
public long timestamp(){
return System.currentTimeMillis();
}
/**
* 口令验证
*
* @param code 账户
* @param totp 待验证的口令
* @return 符合返回true
*/
public boolean verify(String code, String totp) {
String temp = generateTOTP(timestamp(), code, false);
return temp.equals(totp);
}
/**
* 柔性口令验证
*
* @param code 账户
* @param totp 待验证的口令
* @return 符合返回true
*/
public boolean verifyFlexibility(String code, String totp) {
long now = timestamp();
String temp = generateTOTP(now, code, false);
if (temp.equals(totp)) {
return true;
}
temp = generateTOTP(now, code, true);
return temp.equals(totp);
}
/**
* 获取动态因子
*
* @param targetTime 指定时间
* @return long
*/
private long timeFactor(long targetTime) {
return (targetTime - INITIAL_TIME) / this.step;
}
/**
* 生成TOTP
*
* @param key 密码
* @param tc 时间
* @return 返回生成的结果
*/
private String generateTOTPHmacSHA256(String key, String tc) {
StringBuilder timeBuilder = new StringBuilder(tc);
while (timeBuilder.length() < 16) {
timeBuilder.insert(0, "0");
}
tc = timeBuilder.toString();
byte[] msg = hexStr2Bytes(tc);
byte[] k = key.getBytes();
byte[] hash = hmacSha("HmacSHA256", k, msg);
return truncate(hash);
}
/**
* 哈希加密
*
* @param crypto 加密算法
* @param keyBytes 密钥数组
* @param text 加密内容
* @return byte[]
*/
private byte[] hmacSha(String crypto, byte[] keyBytes, byte[] text) {
try {
Mac hmac;
hmac = Mac.getInstance(crypto);
SecretKeySpec macKey = new SecretKeySpec(keyBytes, "AES");
hmac.init(macKey);
return hmac.doFinal(text);
} catch (GeneralSecurityException gse) {
throw new UndeclaredThrowableException(gse);
}
}
private byte[] hexStr2Bytes(String hex) {
byte[] bArray = new BigInteger("10" + hex, 16).toByteArray();
byte[] ret = new byte[bArray.length - 1];
System.arraycopy(bArray, 1, ret, 0, ret.length);
return ret;
}
/**
* 截断函数
*
* @param target 20字节的字符串
* @return String
*/
private String truncate(byte[] target) {
StringBuilder result;
// 0xf = 15、0x7f = 127、0xff = 255
// 取加密后的最后一个字节的低4位,得到offset
int offset = target[target.length - 1] & 0xf;
// 取加密后的字节数组下标为offset、offset+1、offset+2、offset+3的字节通过移位操作让其刚好是int最大位数32
int binary = ((target[offset] & 0x7f) << 24)
| ((target[offset + 1] & 0xff) << 16)
| ((target[offset + 2] & 0xff) << 8) | (target[offset + 3] & 0xff);
// 根据需要的令牌位数进行取模运算
int otp = binary % DIGITS_POWER[codeDigits];
result = new StringBuilder(Integer.toString(otp));
// 如果位数不够再在前面补0
while (result.length() < codeDigits) {
result.insert(0, "0");
}
return result.toString();
}
}
使用示例:
// 有效期为30s,令牌长度为6;柔性时间10秒
TOTP totp = TOTP.builder()
.secretKey("33174@cB2%8df&34ce3ab17b965a2A64T4f")
.step(30 * 1000)
.flexibilityTime(10 * 1000)
.codeDigits(6)
.build();
// 直接验证,不允许柔性时间
String token = totp.generateTOTP("1025");
System.out.println(token);
Thread.sleep(20*1000);
boolean verify = totp.verify("1025", token);
System.out.println(token+"---"+verify);
// 使用柔性时间验证
String token1 = totp.generateTOTP("1025");
System.out.println(token1);
Thread.sleep(20*1000);
verify = totp.verifyFlexibility("1025", token1);
System.out.println(token1+"---"+verify);
注意这个算法的时间很重要,一旦时间不同步那么将直接导致验证失败。因此最好专门用台服务器来做授时的功能。