Initial commit - TOTP Authenticator for Xiaomi Band 10

This commit is contained in:
deepfal 2025-08-14 12:58:18 +08:00
commit b7ef931edf
19 changed files with 11216 additions and 0 deletions

View File

@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(curl:*)",
"Bash(git init:*)",
"Bash(git add:*)"
],
"deny": [],
"ask": []
}
}

7
.eslintignore Normal file
View File

@ -0,0 +1,7 @@
/.nyc_output
/coverage
/node_modules
/tests/fixtures
/sign
/dist
/build

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
/.nyc_output
/coverage
/node_modules
/sign
/dist
/build
.husky/

16
.npmignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
build
dist
.DS_Store
Thumbs.db
*.log
*.iml
.idea/
.vscode/
.nyc_output
/coverage
/logs

22
.prettierrc.js Normal file
View File

@ -0,0 +1,22 @@
module.exports = {
printWidth: 100, // 指定代码长度,超出换行
tabWidth: 2, // tab 键的宽度
useTabs: false, // 使用空格替代tab
semi: false, // 结尾加上分号
singleQuote: false, // 使用单引号
quoteProps: "consistent", // 要求对象字面量属性是否使用引号包裹,(as-needed: 没有特殊要求,禁止使用,'consistent': 保持一致 , preserve: 不限制,想用就用)
trailingComma: "none", // 不添加对象和数组最后一个元素的逗号
bracketSpacing: false, // 对象中对空格和空行进行处理
jsxBracketSameLine: false, // 在多行JSX元素的最后一行追加 >
requirePragma: false, // 是否严格按照文件顶部的特殊注释格式化代码
insertPragma: false, // 是否在格式化的文件顶部插入Pragma标记以表明该文件被prettier格式化过了
proseWrap: "preserve", // 按照文件原样折行
htmlWhitespaceSensitivity: "ignore", // html文件的空格敏感度控制空格是否影响布局
endOfLine: "auto", // 结尾是 \n \r \n\r auto
overrides: [
{
files: "*.ux",
options: {parser: "vue"}
}
]
}

44
.stylelintrc.js Normal file
View File

@ -0,0 +1,44 @@
module.exports = {
extends: [
"stylelint-config-standard",
"stylelint-config-recess-order"
// "stylelint-selector-bem-pattern"
],
ignoreFiles: ["node_modules", "test", "dist", "**/*.js"],
rules: {
"no-descending-specificity": null,
"color-hex-case": "lower",
"color-hex-length": "short",
"at-rule-no-unknown": null,
"block-no-empty": null,
"selector-pseudo-class-no-unknown": [
true,
{
ignorePseudoClasses: ["blur"]
}
],
"property-no-unknown": [
true,
{
ignoreProperties: [
"placeholder-color",
"gradient-start",
"gradient-center",
"gradient-end",
"caret-color",
"selected-color",
"block-color"
]
}
],
"max-line-length": null,
// "indentation": 2,
// "no-empty-source": null,
"selector-type-no-unknown": [
true,
{
ignoreTypes: ["selected-color", "block-color"]
}
]
}
}

202
README.md Normal file
View File

@ -0,0 +1,202 @@
# TOTP验证器 - 小米手环10版
一个专为小米手环10设计的TOTP时间一次性密码验证器应用支持生成双因素认证码。
## 🎯 项目简介
本项目是一个运行在小米手环10上的验证器应用支持生成TOTP和Steam验证码。通过简洁的界面和触感反馈为用户提供便捷的二步验证体验。
## ✨ 功能特性
- 🔐 **多种验证码支持**支持标准TOTP和Steam验证码算法
- ⏰ **实时倒计时**:弧形进度条显示验证码剩余有效时间
- 📱 **响应式界面**:针对手环屏幕尺寸优化的用户界面
- 📳 **触感反馈**:验证码更新、页面切换时的震动提醒
- 🔄 **自动刷新**:验证码自动更新,无需手动操作
- 📊 **详情查看**:大字体显示验证码,方便查看
- ⚡ **性能优化**:针对手环设备的电量和性能优化
## 🛠️ 技术栈
- **开发框架**:快应用 (QuickApp)
- **UI技术**UX模板语言
- **加密算法**HMAC-SHA1, Base32编码
- **依赖库**crypto-js
- **构建工具**aiot-toolkit
## 📦 项目结构
```
src/
├── app.ux # 应用入口文件
├── manifest.json # 应用配置清单
├── common/
│ ├── accounts.json # 账户配置文件
│ └── logo.png # 应用图标
├── pages/
│ └── index/
│ └── index.ux # 主页面
└── utils/
└── totp.js # TOTP算法实现
```
## 🚀 安装使用
### 环境要求
- Node.js >= 8.10
- aiot-toolkit >= 2.0.4
- 小米手环10设备
### 开发环境搭建
1. **克隆项目**
```bash
git clone <repository-url>
cd totp-authenticator
```
2. **安装依赖**
```bash
npm install
```
3. **开发调试**
```bash
npm start
```
4. **构建发布**
```bash
npm run build
npm run release
```
## ⚙️ 配置说明
### 账户配置
`src/common/accounts.json` 中配置验证器账户:
```json
{
"accounts": [
{
"name": "服务名称",
"username": "用户名",
"secret": "Base32密钥",
"enabled": true,
"type": "totp"
}
],
"settings": {
"title": "TOTP验证器",
"refreshInterval": 20,
"maxDisplayAccounts": 10
}
}
```
### 支持的验证码类型
- `totp`: 标准TOTP验证码6位数字
- `steam`: Steam验证码5位字符
### 密钥格式
- 支持Base32编码的密钥
- 支持40位十六进制密钥
- 密钥示例:`JBSWY3DPEHPK3PXP`
## 🎨 界面说明
### 主界面
- 显示所有启用的验证器账户
- 实时显示验证码和剩余时间
- 弧形进度条指示时间进度
### 详情页面
- 大字体显示验证码(分行显示更清晰)
- Steam验证码单行显示
- 返回按钮和时间提醒
### 颜色系统
- 🟢 绿色:正常状态(时间充足)
- 🟡 橙色警告状态时间不足10秒
- 🔴 红色紧急状态时间不足5秒
## 📱 交互说明
- **点击账户卡片**:进入验证码详情页面
- **点击返回按钮**:返回主界面
- **验证码更新**:自动震动提醒
- **页面切换**:轻微震动反馈
## 🔐 安全须知
- 请妥善保管TOTP密钥避免泄露
- 建议定期备份账户配置
- 删除不再使用的验证器账户
- 注意设备安全,避免他人访问
## 🛠️ 开发说明
### 代码规范
```bash
# 代码检查
npm run lint
# 代码格式化
prettier --write "src/**/*.{ux,js}"
```
### 性能优化
- 页面隐藏时停止定时器以节省电量
- 使用节能模式减少不必要的计算
- 优化动画性能,避免卡顿
### 调试技巧
- 使用 `console.log` 进行调试
- 通过 `aiot start --watch` 实现热重载
- 检查手环设备日志排查问题
## 📋 待办事项
- [ ] 添加账户管理功能
- [ ] 支持二维码扫描添加账户
- [ ] 添加数据同步功能
- [ ] 支持更多验证码算法
- [ ] 优化电量消耗
## 🤝 贡献指南
1. Fork 本项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 🙏 致谢
- 感谢小米开发者社区提供的技术支持
- 感谢快应用框架团队
- 感谢所有贡献者和测试用户
## 📞 联系方式
如有问题或建议,请通过以下方式联系:
- 提交 Issue
- 发起 Pull Request
- 联系项目维护者
---
⌚ **让验证更简单,让手环更智能!**

21
commitlint.config.js Normal file
View File

@ -0,0 +1,21 @@
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
[
"bug", // 此项特别针对bug号用于向测试反馈bug列表的bug修改情况
"feat", // 新功能feature
"fix", // 修补bug
"docs", // 文档documentation
"style", // 格式(不影响代码运行的变动)
"refactor", // 重构即不是新增功能也不是修改bug的代码变动
"test", // 增加测试
"chore", // 构建过程或辅助工具的变动
"revert", // feat(pencil): add graphiteWidth option (撤销之前的commit)
"merge" // 合并分支, 例如: merge前端页面 feature-xxxx修改线程地址
]
]
}
}

3
husky.sh Normal file
View File

@ -0,0 +1,3 @@
npx husky install
npx husky add .husky/pre-commit 'npx lint-staged'
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit ${1}'

0
jsconfig.json Normal file
View File

10071
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "totp",
"version": "1.0.0",
"description": "TOTP Authenticator for Xiaomi Watch",
"engines": {
"node": ">=8.10"
},
"scripts": {
"start": "aiot start --watch",
"build": "aiot build",
"release": "aiot release",
"lint": "eslint --format codeframe --fix --ext .ux,.js src/"
},
"lint-staged": {
"*.{ux,js}": [
"prettier --write",
"eslint --format codeframe --fix",
"git add"
],
"*.{less,css}": [
"prettier --write",
"stylelint --fix --custom-syntax postcss-less",
"git add"
]
},
"devDependencies": {
"@aiot-toolkit/jsc": "^1.0.3",
"aiot-toolkit": "^2.0.4"
},
"dependencies": {
"crypto-js": "^4.2.0"
}
}

8
src/app.ux Normal file
View File

@ -0,0 +1,8 @@
<script>
export default {
onCreate() {
},
onDestroy() {
}
}
</script>

44
src/common/accounts.json Normal file
View File

@ -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
}
}

BIN
src/common/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

1
src/config-watch.json Normal file
View File

@ -0,0 +1 @@
{}

33
src/manifest.json Normal file
View File

@ -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"
}
}
}
}

559
src/pages/index/index.ux Normal file
View File

@ -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>
<!-- 标准TOTP6位数字双行显示使用固定位置容器 -->
<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>

134
src/utils/totp.js Normal file
View File

@ -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 }