一、原理说明
基于 HMAC-SHA1 生成的 20 字节哈希值,通过 偏移量截取法 生成 32 位整数示例:
假设 HMAC-SHA1 生成的 20 字节哈希值为:
1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a(字节编号从 0 到 19,每个字节用十六进制表示)
步骤分解
1、提取最后一个字节
第 19 个字节(最后一个字节)的值为 0x5A(十六进制):
1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a
2、计算偏移量(Offset)
取 0x5A 的 低 4 位(即 0xA,十进制为 10)作为偏移量:
1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a
3、截取 4 字节数据
从偏移量 10 开始,截取连续的 4 个字节:
1f 86 98 69 0e 02 ca 16 61 85 50
ef
7f
19
da 8e 94 5b 55 5a
4、转为32位整数
二进制形式:
01010000 11101111 01111111 00011001
对应十六进制:0x50ef7f19
十进制值:1,359,102,489
5、生成 6 位 OTP
1,359,102,489 % 1,000,000 = 102,489
最终验证码:102489
二、代码示例
import base64
import hashlib
import hmac
import struct
import time
import os
from typing import Uniondef generate_secret_key(length: int = 16) -> str:"""生成安全的 Base32 编码密钥"""random_bytes = os.urandom(length)secret = base64.b32encode(random_bytes).decode('utf-8')return secret.rstrip('=') # 去除填充等号(兼容 Google Authenticator)def hotp(secret: str, counter: int, digits: int = 6) -> str:"""基于 HMAC-SHA1 的 HOTP 算法实现"""key = base64.b32decode(secret.upper() + '=' * ((8 - len(secret) % 8) % 8))msg = struct.pack('>Q', counter)hmac_hash = hmac.new(key, msg, hashlib.sha1).digest()# 动态截断处理offset = hmac_hash[-1] & 0x0Fbinary_code = struct.unpack('>I', hmac_hash[offset:offset + 4])[0] & 0x7FFFFFFFreturn str(binary_code % 10 ** digits).zfill(digits)def totp(secret: str, time_step: int = 30, digits: int = 6) -> str:"""生成当前时间的 TOTP 验证码"""counter = int(time.time() // time_step)return hotp(secret, counter, digits)def verify_totp(secret: str, code: str, time_window: int = 1) -> bool:"""验证 TOTP 码(允许时间窗口容错)"""current_counter = int(time.time() // 30)for i in range(-time_window, time_window + 1):expected_code = hotp(secret, current_counter + i)if hmac.compare_digest(expected_code, code):return Truereturn False# ------------------- 示例用法 -------------------
if __name__ == "__main__":# 1. 生成密钥secret_key = generate_secret_key()print(f"生成的密钥: {secret_key}") # 示例输出: JBSWY3DPEHPK3PXP# 2. 生成当前 TOTP 验证码current_otp = totp(secret_key)print(f"当前验证码: {current_otp}") # 示例输出: 123456# 3. 验证用户输入user_input = input("请输入验证码: ")if verify_totp(secret_key, user_input):print("验证成功!")else:print("验证失败!")
三、如何查看Google Authenticator中的密钥
Google Authenticator在手机上可以导出,导出后是加密的迁移二维码(格式:otpauth-migration://offline?data=…)
通过迁移二维码获取加密数据后,可使用开源工具(如 google-authenticator-exporter)解析二维码链接中的 totpSecret 字段,直接提取密钥
# 安装工具
git clone https://github.com/krissrex/google-authenticator-exporter.git
cd google-authenticator-exporter
npm install
npm run start
# 输入导出的迁移链接,即可解析出密钥
如果单纯的想生成,则可以使用一下代码:
import base64
import hashlib
import hmac
import struct
import timedef hotp(secret: str, counter: int, digits: int = 6) -> str:key = base64.b32decode(secret.upper() + '=' * ((8 - len(secret) % 8) % 8))msg = struct.pack('>Q', counter)hmac_hash = hmac.new(key, msg, hashlib.sha1).digest()# 动态截断处理offset = hmac_hash[-1] & 0x0Fbinary_code = struct.unpack('>I', hmac_hash[offset:offset + 4])[0] & 0x7FFFFFFFreturn str(binary_code % 10 ** digits).zfill(digits)def totp(secret: str, time_step: int = 30, digits: int = 6) -> str:counter = int(time.time() // time_step)return hotp(secret, counter, digits)if __name__ == "__main__":current_otp = totp("33FSBRWL36KGTEJUGRIHCLMSYEL4HESR")print(current_otp)
如果想计算剩余时间:则用 30 - 时间戳 % 30
def totp(secret: str, time_step: int = 30, digits: int = 6) -> str:t = time.time()remain = time_step - t % time_stepcounter = int(t // time_step)return hotp(secret, counter, digits), remain
四、自制客户端
我们可以做成自己的客户端,为了简单,可以做成HTML来实现,不仅轻量,而且跨平台:
以下代码可以直接使用,需要替换的就只有JSON数据片段,换成自己的就可以了:
// TOTP数据const totpData = [{"name": "双因子验证","issuer": "测试","algorithm": "SHA1","digits": "SIX","type": "TOTP","totpSecret": "SDHLINVCJFE35TNKISYP7WKA5TYFUS2B"}];
效果如下:
完整代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>TOTP验证码生成器</title><style>/* 原有样式保持不变 */* {box-sizing: border-box;margin: 0;padding: 0;font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;}body {background: linear-gradient(135deg, #1a2a6c, #b21f1f, #1a2a6c);color: #fff;min-height: 100vh;padding: 20px;}.container {max-width: 900px;margin: 0 auto;background: rgba(0, 0, 0, 0.7);border-radius: 15px;padding: 30px;box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);backdrop-filter: blur(10px);}header {text-align: center;margin-bottom: 30px;padding-bottom: 20px;border-bottom: 2px solid rgba(255, 255, 255, 0.1);}h1 {font-size: 2.5rem;margin-bottom: 10px;background: linear-gradient(to right, #ff7e5f, #feb47b);-webkit-background-clip: text;-webkit-text-fill-color: transparent;text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);}.subtitle {color: #ccc;font-size: 1.1rem;margin-bottom: 20px;}.stats {display: flex;justify-content: space-around;background: rgba(255, 255, 255, 0.1);border-radius: 10px;padding: 15px;margin: 20px 0;}.stat-item {text-align: center;}.stat-value {font-size: 1.8rem;font-weight: bold;color: #4facfe;}.stat-label {font-size: 0.9rem;color: #aaa;}.totp-grid {display: grid;grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));gap: 20px;margin-top: 30px;}.totp-card {background: rgba(30, 30, 46, 0.8);border-radius: 12px;padding: 20px;transition: transform 0.3s, box-shadow 0.3s;position: relative;overflow: hidden;border: 1px solid rgba(255, 255, 255, 0.1);}.totp-card:hover {transform: translateY(-5px);box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);background: rgba(40, 40, 56, 0.9);}.issuer {font-size: 1.3rem;font-weight: bold;margin-bottom: 5px;color: #4facfe;}.name {color: #aaa;font-size: 0.9rem;margin-bottom: 15px;}.code-container {display: flex;align-items: center;justify-content: space-between;margin: 15px 0;}.code {font-size: 2.2rem;font-weight: bold;letter-spacing: 5px;text-align: center;color: #fff;background: rgba(0, 0, 0, 0.3);padding: 10px;border-radius: 8px;font-family: monospace;flex: 1;margin-right: 10px;}.copy-btn {padding: 12px 15px;background: #4facfe;color: white;border: none;border-radius: 8px;cursor: pointer;font-weight: bold;transition: all 0.3s;white-space: nowrap;}.copy-btn:hover {background: #00f2fe;transform: translateY(-2px);box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);}.copy-btn.copied {background: #4CAF50;animation: pulse 0.5s;}@keyframes pulse {0% { transform: scale(1); }50% { transform: scale(1.05); }100% { transform: scale(1); }}.timer {height: 6px;background: rgba(255, 255, 255, 0.1);border-radius: 3px;overflow: hidden;margin-top: 15px;}.timer-bar {height: 100%;background: linear-gradient(to right, #4facfe, #00f2fe);width: 100%;transition: width 1s linear;}.time-left {text-align: right;font-size: 0.9rem;color: #aaa;margin-top: 5px;}.footer {text-align: center;margin-top: 30px;padding-top: 20px;border-top: 2px solid rgba(255, 255, 255, 0.1);color: #aaa;font-size: 0.9rem;}.copy-feedback {position: fixed;top: 20px;left: 50%;transform: translateX(-50%);background: rgba(76, 175, 80, 0.9);color: white;padding: 10px 20px;border-radius: 5px;z-index: 1000;opacity: 0;transition: opacity 0.3s;}.copy-feedback.show {opacity: 1;}@media (max-width: 768px) {.container {padding: 15px;}.totp-grid {grid-template-columns: 1fr;}h1 {font-size: 2rem;}}</style>
</head>
<body><div class="container"><header><h1>TOTP验证码生成器</h1><p class="subtitle">基于时间的一次性密码生成工具</p></header><div class="stats"><div class="stat-item"><div class="stat-value" id="total-count">11</div><div class="stat-label">总账户数</div></div><div class="stat-item"><div class="stat-value" id="update-time">30</div><div class="stat-label">刷新倒计时</div></div><div class="stat-item"><div class="stat-value" id="current-time">00:00:00</div><div class="stat-label">当前时间</div></div></div><div class="totp-grid" id="totp-container"><!-- TOTP卡片将通过JavaScript动态生成 --></div><div class="footer"><p>基于HMAC-SHA1算法 | 每30秒自动刷新 | 安全验证工具</p></div></div><!-- 复制反馈提示 --><div class="copy-feedback" id="copy-feedback">已复制到剪贴板!</div><script>// Base32解码函数function base32Decode(input) {const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";let output = new Uint8Array(Math.ceil(input.length * 5 / 8));let buffer = 0;let bitsLeft = 0;let count = 0;for (let i = 0; i < input.length; i++) {const char = input[i].toUpperCase();if (char === '=' || char === ' ') continue;const index = alphabet.indexOf(char);if (index === -1) continue;buffer = (buffer << 5) | index;bitsLeft += 5;if (bitsLeft >= 8) {output[count++] = (buffer >>> (bitsLeft - 8)) & 0xFF;bitsLeft -= 8;}}return output.slice(0, count);}// 动态截断函数function dynamicTruncation(hmac) {const offset = hmac[hmac.length - 1] & 0x0F;return (((hmac[offset] & 0x7F) << 24) |((hmac[offset + 1] & 0xFF) << 16) |((hmac[offset + 2] & 0xFF) << 8) |(hmac[offset + 3] & 0xFF));}// 生成HOTPfunction hotp(secret, counter, digits = 6) {const key = base32Decode(secret);const msg = new Uint8Array(8);for (let i = 7; i >= 0; i--) {msg[i] = counter & 0xFF;counter >>= 8;}// 使用Web Crypto API进行HMAC-SHA1计算return crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]).then(cryptoKey => {return crypto.subtle.sign("HMAC", cryptoKey, msg);}).then(signature => {const hmac = new Uint8Array(signature);const code = dynamicTruncation(hmac) % Math.pow(10, digits);return code.toString().padStart(digits, '0');});}// 生成TOTPfunction totp(secret, timeStep = 30, digits = 6) {const t = Math.floor(Date.now() / 1000);const remain = timeStep - (t % timeStep);const counter = Math.floor(t / timeStep);return hotp(secret, counter, digits).then(code => {return { code, remain };});}// TOTP数据const totpData = [{"name": "双因子验证","issuer": "测试","algorithm": "SHA1","digits": "SIX","type": "TOTP","totpSecret": "SDHLINVCJFE35TNKISYP7WKA5TYFUS2B"}];// 存储卡片DOM引用的数组let totpCards = [];// 初始渲染所有卡片function renderInitialCards() {const container = document.getElementById('totp-container');container.innerHTML = '';totpCards = totpData.map(item => {const card = document.createElement('div');card.className = 'totp-card';card.innerHTML = `<div class="issuer">${item.issuer}</div><div class="name">${item.name}</div><div class="code-container"><div class="code">正在生成...</div><button class="copy-btn">复制</button></div><div class="timer"><div class="timer-bar" style="width: 100%"></div></div><div class="time-left">剩余时间: 30秒</div>`;container.appendChild(card);// 返回需要更新的元素return {element: card,codeElement: card.querySelector('.code'),timerBar: card.querySelector('.timer-bar'),timeLeft: card.querySelector('.time-left'),copyButton: card.querySelector('.copy-btn')};});// 初始化复制按钮setupCopyButtons();}// 更新单个卡片async function updateCard(card, item) {try {const { code, remain } = await totp(item.totpSecret);const percent = (remain / 30) * 100;card.codeElement.textContent = code;card.timerBar.style.width = `${percent}%`;card.timeLeft.textContent = `剩余时间: ${remain}秒`;} catch (error) {console.error(`生成TOTP时出错: ${item.issuer} - ${item.name}`, error);card.codeElement.textContent = 'ERROR';card.timeLeft.textContent = '生成验证码失败';}}// 更新所有卡片async function updateAllCards() {const updatePromises = totpData.map((item, index) => {return updateCard(totpCards[index], item);});await Promise.all(updatePromises);// 更新时间显示const now = new Date();const timeString = now.toTimeString().split(' ')[0];document.getElementById('current-time').textContent = timeString;// 更新刷新倒计时const currentSeconds = Math.floor(Date.now() / 1000);const refreshTime = 30 - (currentSeconds % 30);document.getElementById('update-time').textContent = refreshTime;}// 复制功能function setupCopyButtons() {const copyButtons = document.querySelectorAll('.copy-btn');const feedback = document.getElementById('copy-feedback');copyButtons.forEach(button => {button.addEventListener('click', function() {const code = this.previousElementSibling.textContent;copyToClipboard(code);// 显示反馈动画this.classList.add('copied');feedback.classList.add('show');setTimeout(() => {this.classList.remove('copied');feedback.classList.remove('show');}, 2000);});});}// 复制到剪贴板函数function copyToClipboard(text) {// 使用现代Clipboard API if (navigator.clipboard) {navigator.clipboard.writeText(text).catch(err => {console.error('复制失败:', err);fallbackCopyText(text);});} else {// 兼容旧浏览器的备用方案 fallbackCopyText(text);}}// 兼容旧浏览器的复制方法function fallbackCopyText(text) {const textArea = document.createElement('textarea');textArea.value = text;textArea.style.position = 'fixed';textArea.style.top = '-1000px';textArea.style.left = '-1000px';document.body.appendChild(textArea);textArea.select();try {document.execCommand('copy');} catch (err) {console.error('备用复制方法失败:', err);}document.body.removeChild(textArea);}// 页面加载时初始化document.addEventListener('DOMContentLoaded', async () => {// 初始渲染卡片renderInitialCards();// 初始更新所有卡片await updateAllCards();// 每秒更新一次setInterval(updateAllCards, 1000);});</script>
</body>
</html>