Initial commit - TOTP Authenticator for Xiaomi Band 10
This commit is contained in:
commit
b7ef931edf
19 files changed
+11216
No files matched your search
@@ -0,0 +1,8 @@
|
||||
<script>
|
||||
export default {
|
||||
onCreate() {
|
||||
},
|
||||
onDestroy() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"name": "Example Service 1",
|
||||
"username": "user1",
|
||||
"secret": "JBSWY3DPEHPK3PXP",
|
||||
"enabled": true,
|
||||
"type": "totp"
|
||||
},
|
||||
{
|
||||
"name": "GitHub",
|
||||
"username": "myusername",
|
||||
"secret": "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ",
|
||||
"enabled": true,
|
||||
"type": "totp"
|
||||
},
|
||||
{
|
||||
"name": "Steam",
|
||||
"username": "steamuser",
|
||||
"secret": "NBSWY3DPEHPK3PXPNBSWY3DPEHPK3PXP",
|
||||
"enabled": true,
|
||||
"type": "steam"
|
||||
},
|
||||
{
|
||||
"name": "Work Server",
|
||||
"username": "employee",
|
||||
"secret": "MFRGG2LTEBYGCZLSMUFA====",
|
||||
"enabled": true,
|
||||
"type": "totp"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft",
|
||||
"username": "user@example.com",
|
||||
"secret": "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
|
||||
"enabled": false,
|
||||
"type": "totp"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"title": "TOTP验证器",
|
||||
"refreshInterval": 20,
|
||||
"maxDisplayAccounts": 10
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"package": "com.example.totp.authenticator",
|
||||
"name": "TOTP",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": 1,
|
||||
"minPlatformVersion": 1000,
|
||||
"icon": "/common/logo.png",
|
||||
"deviceTypeList": [
|
||||
"watch"
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"name": "system.router"
|
||||
},
|
||||
{
|
||||
"name": "system.vibrator"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"logLevel": "log",
|
||||
"designWidth": "device-width",
|
||||
"disableAnimation": true,
|
||||
"launchAnimation": false
|
||||
},
|
||||
"router": {
|
||||
"entry": "pages/index",
|
||||
"pages": {
|
||||
"pages/index": {
|
||||
"component": "index"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<div class="totp-app">
|
||||
<!-- 顶部标题区域 -->
|
||||
<div class="header">
|
||||
<text class="header-title" style="font-size: 22px; top:5px">{{appTitle}}</text>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="header-divider"></div>
|
||||
|
||||
<!-- 账户列表 -->
|
||||
<list class="totp-list" style="position: absolute; top: 70px; bottom: 70px; left: 2px; right: 2px; width: 208px;">
|
||||
<list-item class="totp-item" style="height: 115px; margin-bottom: 10px; background-color: #1a1a1a; border-radius: 15px; border: 1px solid #333; flex-direction: column; justify-content: space-between; padding: 8px;" type="account" for="{{totpList}}" onclick="showDetail($idx)">
|
||||
<div style="flex-direction: column; width: 100%; margin-bottom: 8px;">
|
||||
<text style="font-size: 24px; color: #fff; font-weight: bold; margin-bottom: 3px; text-align: left; width: 100%; line-height: 26px;">{{$item.name}}</text>
|
||||
<text style="font-size: 18px; color: #999; text-align: left; width: 100%; line-height: 20px;" show="{{$item.usr}}">{{$item.usr}}</text>
|
||||
</div>
|
||||
<div style="align-items: center; justify-content: center; width: 100%; height: 45px;">
|
||||
<text style="font-size: 32px; color: #00ff88; font-family: monospace; font-weight: bold; letter-spacing: 4px; text-align: center; width: 100%; line-height: 36px;">{{$item.otp}}</text>
|
||||
</div>
|
||||
</list-item>
|
||||
</list>
|
||||
|
||||
<!-- 无账户时的占位符 -->
|
||||
<div class="empty-placeholder" show="{{totpList.length == 0}}">
|
||||
<text class="empty-icon">🔑</text>
|
||||
<text class="empty-text">暂无验证器</text>
|
||||
</div>
|
||||
|
||||
<!-- 底部弧形倒计时进度条 -->
|
||||
<div class="countdown-container" show="{{totpList.length > 0}}">
|
||||
<!-- 弧形进度条 -->
|
||||
<progress style="position: absolute; left: 0px; bottom: 10px; center-x: 106px; center-y: 0px; width: 212px; height: 100px; radius: 100px; start-angle: 240deg; total-angle: -120deg; stroke-width: 8px; background-color: rgba(255, 255, 255, 0.2); color: {{progressColor}};" percent="{{timeProgress}}" type="arc"></progress>
|
||||
<!-- 倒计时文字 -->
|
||||
<text class="countdown-text" style="position: absolute; bottom: 35px; left: 0px; right: 0px; text-align: center; font-size: 20px; font-weight: bold; color: {{textColor}}; transition: color 500ms ease-out;">{{timeLeft}}秒</text>
|
||||
</div>
|
||||
|
||||
<!-- 详情页面 -->
|
||||
<div class="detail-page" show="{{showDetailPage}}">
|
||||
<div class="detail-content">
|
||||
<text class="detail-service">{{selectedItem.name}}</text>
|
||||
<text class="detail-account" show="{{selectedItem.usr}}">{{selectedItem.usr}}</text>
|
||||
<div class="detail-code-container">
|
||||
<!-- Steam验证码:5位字符,一行显示 -->
|
||||
<text class="detail-code-single" if="{{selectedItem.type === 'steam'}}">{{selectedItem.otp}}</text>
|
||||
<!-- 标准TOTP:6位数字,双行显示,使用固定位置容器 -->
|
||||
<block if="{{selectedItem.type !== 'steam'}}">
|
||||
<!-- 第一行:3个数字 -->
|
||||
<div class="detail-code-row">
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(0, 1)}}</text>
|
||||
</div>
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(1, 2)}}</text>
|
||||
</div>
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(2, 3)}}</text>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二行:3个数字 -->
|
||||
<div class="detail-code-row">
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(3, 4)}}</text>
|
||||
</div>
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(4, 5)}}</text>
|
||||
</div>
|
||||
<div class="detail-code-digit">
|
||||
<text class="detail-code-num">{{selectedItem.otp.substring(5, 6)}}</text>
|
||||
</div>
|
||||
</div>
|
||||
</block>
|
||||
</div>
|
||||
<text class="detail-time">剩余时间 {{timeLeft}} 秒</text>
|
||||
</div>
|
||||
|
||||
<!-- 底部返回按钮容器 -->
|
||||
<div class="detail-footer">
|
||||
<div class="detail-back-btn" onclick="hideDetail">
|
||||
<text class="detail-back-icon">←</text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { TOTP, SteamTotp } from '../../utils/totp'
|
||||
import accountsConfig from '../../common/accounts.json'
|
||||
import vibrator from '@system.vibrator'
|
||||
|
||||
// Key class similar to BandTOTP
|
||||
class TOTPKey {
|
||||
constructor(key, name, usr = '', type = 'totp') {
|
||||
this.name = name
|
||||
this.usr = usr
|
||||
this.key = key
|
||||
this.type = type // 新增类型字段:'totp' 或 'steam'
|
||||
this.update()
|
||||
}
|
||||
|
||||
update() {
|
||||
try {
|
||||
// 根据类型选择算法
|
||||
if (this.type === 'steam') {
|
||||
this.otp = SteamTotp(this.key)
|
||||
} else {
|
||||
this.otp = TOTP(this.key)
|
||||
}
|
||||
} catch (error) {
|
||||
this.otp = "ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
private: {
|
||||
totpList: [],
|
||||
timeLeft: 30,
|
||||
timeProgress: 100,
|
||||
progressColor: '#00ff88',
|
||||
textColor: '#fff',
|
||||
showDetailPage: false,
|
||||
selectedItem: null,
|
||||
selectedIndex: 0,
|
||||
updateInterval: null,
|
||||
// Vela电池优化:性能模式控制
|
||||
isVisible: true,
|
||||
performanceMode: 'normal', // normal, power-save, high-performance
|
||||
lastUpdateTime: 0,
|
||||
// 配置相关
|
||||
appTitle: "TOTP验证器",
|
||||
refreshInterval: 100
|
||||
},
|
||||
|
||||
onInit() {
|
||||
|
||||
// 加载配置文件
|
||||
this.loadAccountsFromConfig()
|
||||
|
||||
// 设置初始颜色
|
||||
this.updateColors(this.timeLeft)
|
||||
|
||||
this.startTimer()
|
||||
},
|
||||
|
||||
loadAccountsFromConfig() {
|
||||
try {
|
||||
|
||||
// 加载设置
|
||||
if (accountsConfig.settings) {
|
||||
this.appTitle = accountsConfig.settings.title || "TOTP验证器"
|
||||
this.refreshInterval = accountsConfig.settings.refreshInterval || 100
|
||||
}
|
||||
|
||||
// 加载启用的账户
|
||||
const enabledAccounts = accountsConfig.accounts.filter(account => account.enabled)
|
||||
|
||||
// 转换为TOTPKey实例
|
||||
this.totpList = enabledAccounts.map(account => {
|
||||
return new TOTPKey(
|
||||
account.secret,
|
||||
account.name,
|
||||
account.username,
|
||||
account.type || 'totp' // 如果没有指定type,默认为totp
|
||||
)
|
||||
})
|
||||
|
||||
this.totpList.forEach(() => {
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load accounts config:', error)
|
||||
// 如果加载配置失败,使用默认账户
|
||||
this.loadDefaultAccounts()
|
||||
}
|
||||
},
|
||||
|
||||
loadDefaultAccounts() {
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.isVisible = true
|
||||
this.updateCodes()
|
||||
this.startTimer()
|
||||
},
|
||||
|
||||
onHide() {
|
||||
this.isVisible = false
|
||||
// Vela优化:页面隐藏时切换到节能模式
|
||||
this.performanceMode = 'power-save'
|
||||
|
||||
// Vela优化:页面隐藏时停止动画以节省电量
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval)
|
||||
this.updateInterval = null
|
||||
}
|
||||
// 清除所有动画状态
|
||||
this.removeExpiringAnimation()
|
||||
},
|
||||
|
||||
onDestroy() {
|
||||
// Vela生命周期:确保清理所有资源
|
||||
this.isVisible = false
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval)
|
||||
this.updateInterval = null
|
||||
}
|
||||
},
|
||||
|
||||
updateCodes() {
|
||||
|
||||
// Vela优化:添加更新动画效果
|
||||
this.totpList.forEach((item, index) => {
|
||||
try {
|
||||
const oldOtp = item.otp
|
||||
item.update()
|
||||
|
||||
// 如果代码发生变化,触发更新动画
|
||||
if (oldOtp !== item.otp && oldOtp !== "ERROR") {
|
||||
this.triggerCodeUpdateAnimation()
|
||||
}
|
||||
} catch (error) {
|
||||
item.otp = "ERROR"
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
triggerCodeUpdateAnimation() {
|
||||
// 简化实现,避免DOM操作错误
|
||||
},
|
||||
|
||||
updateColors(timeLeft) {
|
||||
let newProgressColor, newTextColor
|
||||
|
||||
if (timeLeft <= 5) {
|
||||
newProgressColor = '#ff6b6b'
|
||||
newTextColor = '#ff6b6b'
|
||||
} else if (timeLeft <= 10) {
|
||||
newProgressColor = '#ffa502'
|
||||
newTextColor = '#ffa502'
|
||||
} else {
|
||||
newProgressColor = '#00ff88'
|
||||
newTextColor = '#fff'
|
||||
}
|
||||
|
||||
// 只在颜色真正变化时才更新
|
||||
if (this.progressColor !== newProgressColor) {
|
||||
this.progressColor = newProgressColor
|
||||
}
|
||||
if (this.textColor !== newTextColor) {
|
||||
this.textColor = newTextColor
|
||||
}
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval)
|
||||
}
|
||||
if (this.progressInterval) {
|
||||
clearInterval(this.progressInterval)
|
||||
}
|
||||
|
||||
// 使用统一的更新函数,确保时间和进度条完全同步
|
||||
const updateAll = () => {
|
||||
const now = Date.now()
|
||||
const millisecondsInCycle = now % 30000
|
||||
const secondsInCycle = Math.floor(millisecondsInCycle / 1000)
|
||||
const newTimeLeft = 30 - secondsInCycle
|
||||
|
||||
// 使用同一个时间源计算进度条和倒计时
|
||||
this.timeProgress = (30000 - millisecondsInCycle) / 300
|
||||
|
||||
// 更新倒计时文字
|
||||
if (this.timeLeft !== newTimeLeft) {
|
||||
this.timeLeft = newTimeLeft
|
||||
|
||||
// 只在时间变化时更新颜色
|
||||
this.updateColors(newTimeLeft)
|
||||
|
||||
// 添加紧急状态动画
|
||||
if (newTimeLeft <= 5) {
|
||||
this.addExpiringAnimation()
|
||||
} else {
|
||||
this.removeExpiringAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
// 在新周期开始时更新验证码
|
||||
if (millisecondsInCycle < 200 && secondsInCycle === 0) {
|
||||
this.updateCodes()
|
||||
|
||||
// 验证码刷新时短震动+长震动提醒
|
||||
vibrator.vibrate({
|
||||
mode: 'short'
|
||||
})
|
||||
setTimeout(() => {
|
||||
vibrator.vibrate({
|
||||
mode: 'long'
|
||||
})
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// 立即执行一次
|
||||
updateAll()
|
||||
|
||||
// 统一使用10ms更新,确保进度条和时间完全同步
|
||||
this.updateInterval = setInterval(updateAll, 10)
|
||||
},
|
||||
|
||||
restartTimer() {
|
||||
// 重新启动定时器以应用新的性能设置
|
||||
this.startTimer()
|
||||
},
|
||||
|
||||
addExpiringAnimation() {
|
||||
// 简化实现,避免DOM操作错误
|
||||
},
|
||||
|
||||
removeExpiringAnimation() {
|
||||
// 简化实现,避免DOM操作错误
|
||||
},
|
||||
|
||||
showDetail(index) {
|
||||
if (this.totpList.length > 0 && index >= 0 && index < this.totpList.length) {
|
||||
this.selectedIndex = index
|
||||
this.selectedItem = this.totpList[index]
|
||||
this.showDetailPage = true
|
||||
|
||||
// 进入详情页震动反馈
|
||||
vibrator.vibrate({
|
||||
mode: 'short'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
hideDetail() {
|
||||
this.showDetailPage = false
|
||||
this.selectedItem = null
|
||||
|
||||
// 返回时震动反馈
|
||||
vibrator.vibrate({
|
||||
mode: 'short'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.totp-app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
position: fixed;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部标题 */
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #000;
|
||||
flex-shrink: 0;
|
||||
/* 移除多余的边框 */
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-divider {
|
||||
position: absolute;
|
||||
top: 55px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #333, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 列表区域 - 简化版本 */
|
||||
.totp-list {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.totp-item {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 空状态占位符 */
|
||||
.empty-placeholder {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 底部弧形倒计时 */
|
||||
.countdown-container {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
height: 100px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.countdown-text {
|
||||
color: #fff;
|
||||
transition: color 500ms ease-out;
|
||||
}
|
||||
|
||||
/* 详情页面 */
|
||||
.detail-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
padding: 40px 20px 20px 20px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detail-service {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-account {
|
||||
font-size: 22px;
|
||||
color: #999;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-code-container {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 25px;
|
||||
padding: 20px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #333;
|
||||
height: 120px;
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.detail-code-row {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.detail-code-digit {
|
||||
width: 30px;
|
||||
height: 50px;
|
||||
/* background-color: rgba(0, 255, 136, 0.1);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 8px; */
|
||||
margin: 0 1px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-code-num {
|
||||
font-size: 32px;
|
||||
color: #00ff88;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.detail-code-line {
|
||||
font-size: 36px;
|
||||
color: #00ff88;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
line-height: 44px;
|
||||
margin: 0px 0;
|
||||
}
|
||||
|
||||
.detail-code-single {
|
||||
font-size: 32px;
|
||||
color: #00ff88;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
letter-spacing: 3px;
|
||||
text-align: center;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
font-size: 22px;
|
||||
color: #AAA;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 底部按钮容器 */
|
||||
.detail-footer {
|
||||
height: 100px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-back-btn {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 35px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 2px solid #333;
|
||||
}
|
||||
|
||||
.detail-back-icon {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
var CryptoJS = require("crypto-js");
|
||||
let timeOffset=0
|
||||
function time(){
|
||||
return Math.floor(Date.now() / 1000) + (timeOffset || 0);
|
||||
}
|
||||
function TOTP(key) {
|
||||
|
||||
const timeStep = 30; // 默认时间步长为 30 秒
|
||||
const currentTime = time()
|
||||
const timeCounter = Math.floor(currentTime / timeStep).toString(16).padStart(16, '0'); // 时间计数器,16位
|
||||
|
||||
try {
|
||||
// 使用 Base32 解码并返回 WordArray
|
||||
const decodedKey = base32Decode(key);
|
||||
|
||||
// 使用 HMAC-SHA1 计算哈希值
|
||||
const hmacHash = CryptoJS.HmacSHA1(CryptoJS.enc.Hex.parse(timeCounter),decodedKey);
|
||||
|
||||
// 动态截取
|
||||
const hmacBytes = hexToBytes(hmacHash.toString());
|
||||
const offset = hmacBytes[hmacBytes.length - 1] & 0xf;
|
||||
const otp =
|
||||
((hmacBytes[offset] & 0x7f) << 24) |
|
||||
(hmacBytes[offset + 1] << 16) |
|
||||
(hmacBytes[offset + 2] << 8) |
|
||||
hmacBytes[offset + 3];
|
||||
|
||||
// 生成 6 位 OTP
|
||||
const result = (otp % 1000000).toString().padStart(6, '0');
|
||||
return result
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Steam验证码实现
|
||||
function SteamTotp(secret) {
|
||||
secret = bufferizeSecret(secret); // 确保密钥的处理函数兼容
|
||||
// 创建模拟的 8 字节 buffer
|
||||
let bytes = new Uint8Array(8).fill(0);
|
||||
let timeDiv30 = Math.floor(time() / 30);
|
||||
// 将高位写入前 4 字节(为 0)
|
||||
bytes[4] = (timeDiv30 >> 24) & 0xff;
|
||||
bytes[5] = (timeDiv30 >> 16) & 0xff;
|
||||
bytes[6] = (timeDiv30 >> 8) & 0xff;
|
||||
bytes[7] = timeDiv30 & 0xff;
|
||||
// 使用 CryptoJS 计算 HMAC
|
||||
let hmac = CryptoJS.HmacSHA1(CryptoJS.lib.WordArray.create(bytes), secret);
|
||||
|
||||
// 转换 HMAC 为字节数组
|
||||
let hmacBytes = wordArrayToUint8Array(hmac);
|
||||
|
||||
// 获取偏移量
|
||||
const start = hmacBytes[19] & 0x0f;
|
||||
|
||||
// 截取 4 字节
|
||||
hmacBytes = hmacBytes.slice(start, start + 4);
|
||||
|
||||
// 转换为无符号 32 位整数并取 31 位
|
||||
let fullcode =
|
||||
((hmacBytes[0] << 24) | (hmacBytes[1] << 16) | (hmacBytes[2] << 8) | hmacBytes[3]) & 0x7fffffff;
|
||||
|
||||
// 生成验证码
|
||||
const chars = '23456789BCDFGHJKMNPQRTVWXY';
|
||||
let code = '';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
code += chars.charAt(fullcode % chars.length);
|
||||
fullcode /= chars.length;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
function bufferizeSecret(secret) {
|
||||
if (typeof secret === 'string') {
|
||||
// 检查是否为 hex 编码
|
||||
if (secret.match(/^[0-9a-f]{40}$/i)) {
|
||||
return CryptoJS.enc.Hex.parse(secret); // 解析为 WordArray
|
||||
} else {
|
||||
// 假设是 base32 编码
|
||||
return base32Decode(secret); // 解析为 WordArray
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是字符串,假设已经是 WordArray 或其他支持的格式
|
||||
return secret;
|
||||
}
|
||||
|
||||
// 将 WordArray 转换为字节数组(Uint8Array)
|
||||
function wordArrayToUint8Array(wordArray) {
|
||||
const words = wordArray.words;
|
||||
const sigBytes = wordArray.sigBytes;
|
||||
const bytes = new Uint8Array(sigBytes);
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < sigBytes) {
|
||||
bytes[i++] = (words[j] >>> 24) & 0xff;
|
||||
bytes[i++] = (words[j] >>> 16) & 0xff;
|
||||
bytes[i++] = (words[j] >>> 8) & 0xff;
|
||||
bytes[i++] = words[j++] & 0xff;
|
||||
}
|
||||
return bytes.slice(0, sigBytes);
|
||||
}
|
||||
|
||||
// Base32 解码函数
|
||||
function base32Decode(input) {
|
||||
const base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
input = input.toUpperCase().replace(/=+$/, ''); // 去掉填充符
|
||||
|
||||
let bits = '';
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const val = base32chars.indexOf(input[i]);
|
||||
if (val === -1) throw new Error('Invalid Base32 character');
|
||||
bits += val.toString(2).padStart(5, '0');
|
||||
}
|
||||
|
||||
let hex="";
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||||
hex+=parseInt(bits.substring(i, i + 8), 2).toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
return CryptoJS.enc.Hex.parse(hex);
|
||||
}
|
||||
|
||||
// 将十六进制字符串转换为字节数组
|
||||
function hexToBytes(hex) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes.push(parseInt(hex.substr(i, 2), 16));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export { TOTP, SteamTotp, timeOffset }
|
||||
Reference in new issue
Block a user