概念
一次性密碼(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秒;
#代碼實作
package com.springboot.demo.util;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Base64;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Objects;
@Slf4j
@Component
public class GoogleGeneratorUtil {
// 生成的key長度( Generate secret key length)
public static final int SECRET_SIZE = 10;
public static final String SEED = "22150146801713967E8g";
// Java實作随機數算法
public static final String RANDOM_NUMBER_ALGORITHM = "SHA1PRNG";
// 最多可偏移的時間
private static int OTP_WINDOW_SIZE = 1; // default 1 - max 17
/**
* 建立密鑰
*
* @return {@link String}
*/
public static String generateSecretKey() throws NoSuchAlgorithmException {
SecureRandom sr = null;
try {
sr = SecureRandom.getInstance(RANDOM_NUMBER_ALGORITHM);
sr.setSeed(Base64.decodeBase64(SEED));
byte[] buffer = sr.generateSeed(SECRET_SIZE);
Base32 codec = new Base32();
byte[] bEncodedKey = codec.encode(buffer);
String encodedKey = new String(bEncodedKey);
return encodedKey;
} catch (NoSuchAlgorithmException e) {
// should never occur... configuration error
log.error("otp生成SecretKey失敗", e);
throw e;
}
}
/**
* 這個format不可以修改,身份驗證器無法識别二維碼
* 二維碼 url 格式
* @param user
* @param secret
* @return
*/
public static String getQRBarcodeStr(String user, String secret) {
String format = "otpauth://totp/Otpdemo:%s?secret=%s&issuer=Otpdemo";
return String.format(format, user, secret);
}
public static boolean check_code(String secret, String code, long timeMsec) {
Base32 codec = new Base32();
byte[] decodedKey = codec.decode(secret);
// convert unix msec time into a 30 second "window"
// this is per the TOTP spec (see the RFC for details)
long t = (timeMsec / 1000L) / 30L;
// Window is used to check codes generated in the near past.
// You can use this value to tune how far you're willing to go.
for (int i = -OTP_WINDOW_SIZE; i <= OTP_WINDOW_SIZE; ++i) {
long hash;
try {
hash = verify_code(decodedKey, t + i);
} catch (Exception e) {
// Yes, this is bad form - but
// the exceptions thrown would be rare and a static
// configuration problem
// e.printStackTrace ();
log.error("失敗", e);
throw new RuntimeException(e.getMessage(), e);
// return false;
}
if (Objects.equals(code, addZero(hash))) {
return true;
}
}
// The validation code is invalid.
return false;
}
public static int verify_code(byte[] key, long t) throws NoSuchAlgorithmException, InvalidKeyException {
byte[] data = new byte[8];
long value = t;
for (int i = 8; i-- > 0; value >>>= 8) {
data[i] = (byte) value;
}
SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1");
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(signKey);
byte[] hash = mac.doFinal(data);
int offset = hash[20 - 1] & 0xF;
// We're using a long because Java hasn't got unsigned int.
long truncatedHash = 0;
for (int i = 0; i < 4; ++i) {
truncatedHash <<= 8;
// We are dealing with signed bytes:
// we just keep the first byte.
truncatedHash |= (hash[offset + i] & 0xFF);
}
truncatedHash &= 0x7FFFFFFF;
truncatedHash %= 1000000;
return (int) truncatedHash;
}
public static String addZero(long code) {
return String.format("%06d", code);
}
// @NacosValue(value = "${otp.window.size}", autoRefreshed = true)
int otpWindowSize = 1;
public void setOtpWindowSize(int otpWindowSize) {
if (otpWindowSize > 17 || otpWindowSize < 0) {
throw new RuntimeException("otp.window.size最大值是17,最小為1");
}
OTP_WINDOW_SIZE = otpWindowSize;
}
}
測試驗證
import com.springboot.demo.util.GoogleGeneratorUtil;
import com.springboot.demo.util.QrCodeUtil;
import org.apache.commons.codec.binary.Base32;
/**
* @author li
* @version 1.0
* @date 2023/05/11 09:58
*/
public class testOtp {
public static void main(String[] args) throws Exception {
//生成秘鑰
String secret = GoogleGeneratorUtil.generateSecretKey();
//生成認證url
String qrcode = GoogleGeneratorUtil.getQRBarcodeStr("test", secret);
//二維碼Base64
String qrcodeBase64 = QrCodeUtil.encode(qrcode);
System.out.println(qrcode);
System.out.println(qrcodeBase64);
//生成6位otp驗證碼
Base32 codec = new Base32();
byte[] decodedKey = codec.decode(secret);
long t = (System.currentTimeMillis() / 1000L) / 30L;
int realOtpCode = GoogleGeneratorUtil.verify_code(decodedKey,t+1);
//校驗otp6位驗證碼
String otpCode = GoogleGeneratorUtil.addZero(realOtpCode);
System.out.println("otpCode: "+ otpCode);
//校驗otp6位數字是否正确
Boolean flag = GoogleGeneratorUtil.check_code(secret,otpCode,System.currentTimeMillis());
System.out.println(flag);
}
}