This commit is contained in:
“xHuPo” 2025-06-09 13:35:15 +08:00
commit 2b8870a40e
51 changed files with 5845 additions and 0 deletions

89
app.js Normal file
View file

@ -0,0 +1,89 @@
//app.js
const { syncTokens, showToast } = require('./utils/util');
const config = require('./utils/config');
const cloud = require('./utils/cloud');
App({
onLaunch: function (options) {
// 获取本地OTP信息
this.getLocalOtpInfo();
// 不再自动登录,只在用户点击登录按钮时才登录
},
getLocalOtpInfo() {
try {
const otpList = wx.getStorageSync('tokens');
if (otpList) {
this.globalData.otpList = otpList;
}
} catch (e) {
console.error("获取本地OTP信息失败", e);
}
},
async userLogin(options) {
try {
// 获取微信登录凭证
const { code } = await new Promise((resolve, reject) => {
wx.login({
success: resolve,
fail: reject
});
});
// 调用后端登录接口
const loginResult = await cloud.request({
url: config.API_ENDPOINTS.AUTH.LOGIN,
method: 'POST',
data: { code },
needToken: false
});
// 保存登录信息到storage
wx.setStorageSync(config.JWT_CONFIG.storage.access, loginResult.accessToken);
wx.setStorageSync(config.JWT_CONFIG.storage.refresh, loginResult.refreshToken);
// 初始化基本用户信息
const userInfo = {
avatarUrl: wx.getStorageSync('userAvatar') || '/images/default-avatar.png',
nickName: wx.getStorageSync('userNickName') || '微信用户'
};
this.globalData.userInfo = userInfo;
// 同步OTP数据
await this.syncOtpData();
return userInfo;
} catch (error) {
console.error('登录失败:', error);
showToast('登录失败,请重试', 'none');
throw error;
}
},
async syncOtpData() {
try {
// 获取本地数据
const localTokens = this.globalData.otpList;
// 使用util.js中的syncTokens函数进行同步
// 注意syncTokens函数已经被更新为使用cloud模块
const syncedTokens = await syncTokens(localTokens);
// 更新本地存储和全局数据
wx.setStorageSync('tokens', syncedTokens);
this.globalData.otpList = syncedTokens;
} catch (error) {
console.error('同步OTP数据失败', error);
// 同步失败不显示错误提示,因为这是自动同步过程
}
},
globalData: {
version: 103,
otpList: [],
userInfo: null
}
})

38
app.json Normal file
View file

@ -0,0 +1,38 @@
{
"pages":[
"pages/index/index",
"pages/mine/mine",
"pages/edit/edit",
"pages/form/form",
"pages/info/info"
],
"window": {
"backgroundColor": "#f8f9fa",
"backgroundTextStyle": "dark",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTitleText": "动态验证码",
"navigationBarTextStyle":"black"
},
"tabBar": {
"borderStyle":"black",
"backgroundColor":"#ffffff",
"color":"#999999",
"selectedColor": "#007AFF",
"list": [
{
"pagePath": "pages/index/index",
"text": "验证码",
"iconPath": "images/indexoff.png",
"selectedIconPath": "images/index.png"
},
{
"pagePath": "pages/mine/mine",
"text": "我的",
"iconPath": "images/mineoff.png",
"selectedIconPath": "images/mine.png"
}
]
},
"sitemapLocation": "sitemap.json",
"style":"v2"
}

4
app.wxss Normal file
View file

@ -0,0 +1,4 @@
/**app.wxss**/
page {
background: #f8f9fa;
}

BIN
images/default-avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

BIN
images/index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
images/indexoff.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
images/mine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

BIN
images/mineoff.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

BIN
images/share.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

233
pages/edit/edit.js Normal file
View file

@ -0,0 +1,233 @@
// pages/edit/edit.js
let util = require('../../utils/util')
Page({
/**
* 页面的初始数据
*/
data: {
issuer: '',
remark: '',
secret: '',
type: '',
counter: 0,
isHotp: false,
isSubmitting: false,
token: [],
token_id: null,
hasLoaded: false
},
/**
* 生命周期函数--监听页面加载
*/
onLoad: function (options) {
if (!options.token_id) {
wx.showToast({
title: '参数错误',
icon: 'error',
duration: 2000
})
setTimeout(() => {
wx.navigateBack({ delta: 1 })
}, 2000)
return
}
this.setData({
token_id: options.token_id
})
this.loadTokenData()
},
/**
* 加载令牌数据
*/
loadTokenData: function() {
const self = this
wx.getStorage({
key: 'tokens',
success: function(res) {
const tokens = res.data
const targetToken = tokens.find(token => String(token.id) === String(self.data.token_id))
if (!targetToken) {
wx.showToast({
title: '令牌不存在',
icon: 'error',
duration: 2000
})
setTimeout(() => {
wx.navigateBack({ delta: 1 })
}, 2000)
return
}
self.setData({
tokens: tokens,
issuer: targetToken.issuer || '',
remark: targetToken.remark || '',
secret: targetToken.secret || '',
type: targetToken.type || 'totp',
counter: targetToken.counter || 0,
isHotp: targetToken.type === 'hotp',
hasLoaded: true
})
},
fail: function(err) {
wx.showToast({
title: '加载失败',
icon: 'error',
duration: 2000
})
setTimeout(() => {
wx.navigateBack({ delta: 1 })
}, 2000)
}
})
},
// 验证计数器值
validateCounter: function(counter) {
const num = parseInt(counter)
if (isNaN(num) || num < 0) {
wx.showToast({
title: '计数器值必须是非负整数',
icon: 'none',
duration: 2000
})
return false
}
return true
},
// 修改并提交数据
keySubmit: function (e) {
const self = this
const values = e.detail.value
// 防止重复提交
if (this.data.isSubmitting) {
return
}
// 检查数据是否已加载
if (!this.data.hasLoaded) {
wx.showToast({
title: '数据未加载完成',
icon: 'error',
duration: 2000
})
return
}
// 检查必填字段
if (!values.issuer || !values.issuer.trim()) {
wx.showToast({
title: '发行方不能为空',
icon: 'none',
duration: 2000
})
return
}
// 如果是HOTP类型验证计数器值
if (this.data.isHotp && values.counter !== undefined) {
if (!this.validateCounter(values.counter)) {
return
}
}
this.setData({
isSubmitting: true
})
// 创建tokens的副本并更新数据
const updatedTokens = [...this.data.tokens]
const tokenIndex = updatedTokens.findIndex(token => String(token.id) === String(this.data.token_id))
if (tokenIndex === -1) {
wx.showToast({
title: '令牌不存在',
icon: 'error',
duration: 2000
})
this.setData({
isSubmitting: false
})
return
}
// 更新令牌数据
updatedTokens[tokenIndex] = {
...updatedTokens[tokenIndex],
issuer: values.issuer.trim(),
remark: (values.remark || '').trim(),
secret: this.data.secret, // 使用已加载的secret而不是从表单获取
type: this.data.type
}
// 如果是HOTP类型更新计数器值
if (this.data.isHotp && values.counter !== undefined) {
updatedTokens[tokenIndex].counter = parseInt(values.counter)
}
wx.setStorage({
key: 'tokens',
data: updatedTokens,
success: function(res) {
// 更新本地数据
self.setData({
tokens: updatedTokens
})
// 保存到本地存储
wx.setStorageSync('tokens', updatedTokens)
wx.showToast({
title: '更新成功',
icon: 'success',
duration: 1500
})
setTimeout(() => {
wx.navigateBack({
delta: 1,
})
}, 1500)
},
fail: function(err) {
wx.showToast({
title: '保存失败',
icon: 'error',
duration: 2000
})
},
complete: function() {
self.setData({
isSubmitting: false
})
}
})
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload: function () {
// 清理数据,防止下次加载时出现数据混淆
this.setData({
token: [],
token_id: null,
hasLoaded: false,
isSubmitting: false,
issuer: '',
remark: '',
secret: '',
type: '',
counter: 0,
isHotp: false
})
}
})

1
pages/edit/edit.json Normal file
View file

@ -0,0 +1 @@
{}

33
pages/edit/edit.wxml Normal file
View file

@ -0,0 +1,33 @@
<!--pages/edit/edit.wxml-->
<form bindsubmit="keySubmit">
<view class="section">
<view class="section-title">基本信息</view>
<view class="input-box">
<text>Service</text>
<input name="issuer" placeholder="服务名称" placeholder-style="color: #666; font-size: 1rem;" value="{{issuer}}"/>
</view>
<view class="input-box">
<text>Account</text>
<input name="remark" placeholder="帐号备注" placeholder-style="color: #666; font-size: 1rem;" value="{{remark}}"/>
</view>
</view>
<block wx:if="{{isHotp}}">
<view class="section">
<view class="section-title">HOTP设置</view>
<view class="input-box">
<text>当前计数器值</text>
<input type="number" name="counter" value="{{counter}}" placeholder-style="color: #666; font-size: 1rem;" />
<view class="tip-text">修改计数器值会影响验证码生成,请谨慎操作</view>
</view>
</view>
</block>
<view class="button-group">
<view class="add-btn">
<button form-type="submit" disabled="{{isSubmitting}}">
{{isSubmitting ? '保存中...' : '保存修改'}}
</button>
</view>
</view>
</form>

104
pages/edit/edit.wxss Normal file
View file

@ -0,0 +1,104 @@
/* pages/edit/edit.wxss */
page {
background: #f8f9fa;
}
.container {
padding: 20rpx;
box-sizing: border-box;
min-height: 100vh;
}
.section {
margin-bottom: 30rpx;
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.section-title {
color: #333333;
font-size: 32rpx;
font-weight: 500;
margin-bottom: 24rpx;
}
.input-box {
margin-top: 20rpx;
background: transparent;
border: none;
padding: 0;
}
.input-box text {
color: #333333;
font-size: 28rpx;
display: block;
margin-bottom: 12rpx;
}
.input-box input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background: #ffffff;
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
font-size: 28rpx;
color: #333333;
box-sizing: border-box;
}
.input-box input:focus {
border-color: #007AFF;
}
.tip-text {
color: #ff9c10;
font-size: 24rpx;
margin-top: 16rpx;
opacity: 0.8;
}
.button-group {
padding: 0;
margin-top: 40rpx;
display: flex;
justify-content: space-between;
}
.add-btn {
margin-top: 0;
flex: 1;
}
.add-btn button {
width: 100%;
height: 80rpx;
border-radius: 8rpx;
background: #007AFF;
color: white;
font-size: 28rpx;
border: none;
}
.add-btn button[disabled] {
background: rgba(0, 122, 255, 0.5);
color: rgba(255, 255, 255, 0.7);
}
.edit-ad {
position: fixed;
bottom: 0;
width: 100%;
padding: 20rpx;
box-sizing: border-box;
background: #f8f9fa;
}
.edit-ad ad {
margin: 0 auto;
border-radius: 8rpx;
overflow: hidden;
}

196
pages/form/form.js Normal file
View file

@ -0,0 +1,196 @@
// pages/form/form.js
const {
addToken,
parseURL,
validateToken,
showToast
} = require('../../utils/util');
Page({
/**
* 页面的初始数据
*/
data: {
type: 'totp', // 默认选择TOTP
isSubmitting: false // 防止重复提交
},
/**
* 生命周期函数--监听页面加载
*/
onLoad: function (options) {
// 准备初始数据
const initialData = {
type: options.type || 'totp',
formData: {
issuer: '',
remark: '',
secret: '',
algo: 'SHA1',
digits: '6',
period: '30',
counter: '0'
},
pageReady: true // 直接设置为ready状态
};
// 如果有扫描结果,预处理数据
if (options.scan) {
try {
const scanData = decodeURIComponent(options.scan);
const parsedToken = parseURL(scanData);
if (parsedToken) {
initialData.type = parsedToken.type;
initialData.formData = {
issuer: parsedToken.issuer || '',
remark: parsedToken.remark || '',
secret: parsedToken.secret || '',
algo: parsedToken.algo || 'SHA1',
digits: parsedToken.digits || '6',
period: parsedToken.period || '30',
counter: parsedToken.counter || '0'
};
// 立即显示成功提示
wx.showToast({
title: '二维码解析成功',
icon: 'success',
duration: 1500
});
}
} catch (error) {
console.error('解析扫描数据失败:', error);
wx.showToast({
title: '无效的二维码数据',
icon: 'none',
duration: 1500
});
}
}
// 一次性设置所有数据
this.setData(initialData);
},
/**
* 处理类型切换
*/
onTypeChange: function(e) {
this.setData({
type: e.detail.value
});
},
/**
* 验证并格式化表单数据
*/
validateAndFormatForm: function(values) {
try {
// 基本字段验证
if (!values.issuer || !values.issuer.trim()) {
throw new Error('请输入服务名称');
}
if (!values.remark || !values.remark.trim()) {
throw new Error('请输入账号备注');
}
if (!values.secret || !values.secret.trim()) {
throw new Error('请输入密钥');
}
// 格式化数据
const tokenData = {
type: values.type,
issuer: values.issuer.trim(),
remark: values.remark.trim(),
secret: values.secret.trim().toUpperCase(),
algo: values.algo,
digits: parseInt(values.digits, 10)
};
// 类型特定字段
if (values.type === 'totp') {
const period = parseInt(values.period, 10);
if (isNaN(period) || period < 15 || period > 300) {
throw new Error('更新周期必须在15-300秒之间');
}
tokenData.period = period;
} else {
const counter = parseInt(values.counter, 10);
if (isNaN(counter) || counter < 0) {
throw new Error('计数器初始值必须大于等于0');
}
tokenData.counter = counter;
}
// 使用工具函数验证完整的令牌数据
const errors = validateToken(tokenData);
if (errors) {
throw new Error(errors.join('\n'));
}
return tokenData;
} catch (error) {
showToast(error.message, 'none');
return null;
}
},
/**
* 提交数据
*/
keySubmit: async function (e) {
// 防止重复提交
if (this.data.isSubmitting) {
return;
}
this.setData({ isSubmitting: true });
try {
const values = e.detail.value;
const tokenData = this.validateAndFormatForm(values);
if (!tokenData) {
this.setData({ isSubmitting: false });
return;
}
// 添加令牌
await addToken(tokenData);
// 显示成功提示
wx.showToast({
title: '添加成功',
icon: 'success',
mask: true,
duration: 800
});
// 获取页面事件通道
const eventChannel = this.getOpenerEventChannel();
// 通知上一页面刷新数据
if (eventChannel && typeof eventChannel.emit === 'function') {
eventChannel.emit('tokenAdded');
}
// 立即跳转到首页
wx.switchTab({
url: '/pages/index/index'
});
} catch (error) {
console.error('添加令牌失败:', error);
wx.showToast({
title: error.message || '添加失败',
icon: 'none',
duration: 2000
});
} finally {
this.setData({ isSubmitting: false });
}
},
// 扫描功能已移至主页面
})

1
pages/form/form.json Normal file
View file

@ -0,0 +1 @@
{}

82
pages/form/form.wxml Normal file
View file

@ -0,0 +1,82 @@
<!--pages/form/form.wxml-->
<view class="container {{pageReady ? 'page-loaded' : ''}}">
<form bindsubmit="keySubmit">
<!-- 基本信息 -->
<view class="section">
<view class="section-title">基本信息</view>
<view class="input-box">
<text>Service</text>
<input name="issuer" placeholder="服务名称" value="{{formData.issuer}}" placeholder-style="color: #666; font-size: 1rem;" />
</view>
<view class="input-box">
<text>Account</text>
<input name="remark" placeholder="帐号备注" value="{{formData.remark}}" placeholder-style="color: #666; font-size: 1rem;" />
</view>
<view class="input-box">
<text>KEY</text>
<input name="secret" placeholder="Secret" value="{{formData.secret}}" placeholder-style="color: #666; font-size: 1rem;" />
</view>
</view>
<!-- 令牌类型 -->
<view class="section">
<view class="section-title">令牌类型</view>
<view class="input-box">
<text>类型</text>
<radio-group name="type" bindchange="onTypeChange">
<radio value="totp" checked="{{type === 'totp'}}">TOTP (基于时间)</radio>
<radio value="hotp" checked="{{type === 'hotp'}}">HOTP (基于计数器)</radio>
</radio-group>
</view>
</view>
<!-- 高级设置 -->
<view class="section">
<view class="section-title">高级设置</view>
<view class="input-box">
<text>算法</text>
<radio-group name="algo">
<radio value="SHA1" checked="{{!formData.algo || formData.algo === 'SHA1'}}">SHA1</radio>
<radio value="SHA256" checked="{{formData.algo === 'SHA256'}}">SHA256</radio>
<radio value="SHA512" checked="{{formData.algo === 'SHA512'}}">SHA512</radio>
</radio-group>
</view>
<view class="input-box">
<text>验证码位数</text>
<radio-group name="digits">
<radio value="6" checked="{{!formData.digits || formData.digits === '6'}}">6位</radio>
<radio value="7" checked="{{formData.digits === '7'}}">7位</radio>
<radio value="8" checked="{{formData.digits === '8'}}">8位</radio>
</radio-group>
</view>
<!-- TOTP特定设置 -->
<block wx:if="{{type === 'totp'}}">
<view class="input-box">
<text>更新周期(秒)</text>
<input type="number" name="period" value="{{formData.period || '30'}}" />
</view>
</block>
<!-- HOTP特定设置 -->
<block wx:if="{{type === 'hotp'}}">
<view class="input-box">
<text>初始计数器值</text>
<input type="number" name="counter" value="{{formData.counter || '0'}}" />
</view>
</block>
</view>
<!-- 操作按钮 -->
<view class="button-group">
<button
form-type="submit"
type="primary"
disabled="{{isSubmitting}}"
class="submit-button">
{{isSubmitting ? '添加中...' : '确认添加'}}
</button>
</view>
</form>
</view>

142
pages/form/form.wxss Normal file
View file

@ -0,0 +1,142 @@
/* pages/form/form.wxss */
page {
background: #f8f9fa;
padding-bottom: 40rpx;
transition: opacity 0.2s ease;
}
.container {
padding: 20rpx 0;
opacity: 1;
}
/* 页面加载动画 - 优化性能 */
@keyframes quickFadeIn {
from { opacity: 0.8; }
to { opacity: 1; }
}
.page-loaded {
animation: quickFadeIn 0.15s ease-out;
}
/* 区块样式 */
.section {
margin-bottom: 30rpx;
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx;
margin: 0 20rpx 30rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.section-title {
color: #007AFF;
font-size: 32rpx;
font-weight: 500;
margin-bottom: 24rpx;
}
.input-box {
margin-top: 20rpx;
background: transparent;
border: none;
padding: 0;
}
.input-box text {
color: #333333;
font-size: 28rpx;
display: block;
margin-bottom: 12rpx;
}
.input-box input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background: #ffffff;
border: 1rpx solid #e0e0e0;
border-radius: 8rpx;
font-size: 28rpx;
color: #333333;
box-sizing: border-box;
}
.input-box input:focus {
border-color: #007AFF;
}
/* 单选框组样式 */
.input-box radio-group {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
margin-top: 20rpx;
}
.input-box radio {
margin-right: 20rpx;
}
.input-box radio .wx-radio-input {
background: #ffffff !important;
border-color: #e0e0e0 !important;
width: 36rpx !important;
height: 36rpx !important;
}
.input-box radio .wx-radio-input.wx-radio-input-checked {
background: #007AFF !important;
border-color: #007AFF !important;
}
/* 按钮组样式 */
.button-group {
padding: 30rpx 20rpx;
display: flex;
justify-content: center;
}
.submit-button {
width: 100% !important;
height: 80rpx !important;
border-radius: 8rpx !important;
background: #007AFF !important;
color: #ffffff !important;
font-weight: 500 !important;
font-size: 28rpx !important;
border: none !important;
box-shadow: 0 4rpx 16rpx rgba(0, 122, 255, 0.3) !important;
}
.submit-button[disabled] {
background: #e0e0e0 !important;
color: #999999 !important;
opacity: 0.7 !important;
box-shadow: none !important;
}
/* 广告样式 */
.form-ad {
position: fixed;
bottom: 0;
width: 100%;
background: #f8f9fa;
padding: 20rpx;
box-sizing: border-box;
}
.form-ad ad {
margin: 0 auto;
border-radius: 8rpx;
overflow: hidden;
}
/* 错误提示样式 */
.error-text {
color: #ff4444;
font-size: 24rpx;
margin-top: 16rpx;
padding: 0 20rpx;
}

721
pages/index/index.js Normal file
View file

@ -0,0 +1,721 @@
// index.js
const {
generateCode,
getRemainingSeconds,
formatTime,
syncTokens
} = require('../../utils/util');
const storage = require('../../utils/storage');
const eventManager = require('../../utils/eventManager');
Page({
data: {
tokens: [],
remainingSeconds: {}, // 存储每个令牌的剩余时间
loading: true,
syncing: false,
error: null
},
onLoad: function() {
// 初始化页面数据
this.loadTokens();
// 监听令牌更新事件
this.tokensUpdatedListener = (tokens) => {
this.loadTokens();
};
eventManager.on('tokensUpdated', this.tokensUpdatedListener);
// 初始化页面路由
this.pageRoutes = {
form: '/pages/form/form',
edit: '/pages/edit/edit'
};
},
onShow: function() {
// 先加载令牌列表
this.loadTokens();
// 然后延迟100ms刷新验证码确保tokens已经加载
setTimeout(() => {
this.refreshTokens();
}, 100);
},
onPullDownRefresh: function() {
this.refreshTokens().then(() => {
wx.stopPullDownRefresh();
});
},
async loadTokens() {
try {
this.setData({ loading: true, error: null });
// 从本地存储加载令牌
const tokens = await wx.getStorageSync('tokens') || [];
// console.log('[DEBUG] Loaded tokens count:', tokens.length);
// 统计令牌类型
const typeCounts = tokens.reduce((acc, token) => {
acc[token.type] = (acc[token.type] || 0) + 1;
return acc;
}, {});
// console.log('[DEBUG] Token types:', typeCounts);
// 初始化剩余时间
const remainingSeconds = {};
tokens.forEach(token => {
if (token.type === 'totp') {
const period = token.period || 30;
const remaining = getRemainingSeconds(period);
// console.log(`[DEBUG] Token ${token.id} period=${period}, remaining=${remaining}`);
remainingSeconds[token.id] = remaining;
}
});
// 获取当前时间戳
const timestamp = Math.floor(Date.now() / 1000);
// 先更新验证码
let tokensToSet = [...tokens]; // 创建tokens的副本
if (tokensToSet.length > 0) {
const updatedTokens = await this.updateTokenCodes(tokensToSet);
if (Array.isArray(updatedTokens)) {
tokensToSet = updatedTokens;
}
}
// 设置状态
this.setData({
tokens: tokensToSet,
remainingSeconds,
loading: false
});
// 设置定时器定期更新TOTP令牌
this.setupRefreshTimer();
} catch (error) {
console.error('Failed to load tokens:', error);
this.setData({
error: '加载令牌失败: ' + error.message,
loading: false
});
}
},
async refreshTokens() {
try {
const currentTokens = [...this.data.tokens]; // 创建tokens的副本
const updatedTokens = await this.updateTokenCodes(currentTokens);
// 更新剩余时间
const remainingSeconds = {};
updatedTokens.forEach(token => {
if (token.type === 'totp') {
remainingSeconds[token.id] = getRemainingSeconds(token.period || 30);
}
});
this.setData({
tokens: updatedTokens,
remainingSeconds
});
} catch (error) {
console.error('Failed to refresh tokens:', error);
this.setData({
error: '刷新令牌失败: ' + error.message
});
}
},
setupRefreshTimer() {
// 清除现有的定时器
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
// console.log('[DEBUG] Setting up refresh timer...');
// 设置新的定时器,每秒检查一次
this.refreshTimer = setInterval(() => {
// console.log('[DEBUG] Timer tick - checking tokens...');
const currentTokens = [...this.data.tokens]; // 创建tokens的副本
const currentRemainingSeconds = this.data.remainingSeconds || {};
const remainingSeconds = {};
const tokensToUpdate = [];
currentTokens.forEach(token => {
// 处理所有支持的OTP类型
const supportedTypes = ['totp', 'hotp', 'otpauth'];
if (!supportedTypes.includes(token.type)) {
// console.log(`[DEBUG] Skipping unsupported token type: ${token.type}`);
return;
}
// console.log(`[DEBUG] Processing ${token.type.toUpperCase()} token ${token.id || token.name}`);
// 针对不同类型做特殊处理
switch(token.type) {
case 'totp':
// 标准TOTP处理逻辑
break;
case 'hotp':
// HOTP特定处理
// console.log(`[DEBUG] HOTP token detected, counter: ${token.counter}`);
break;
case 'otpauth':
// 扫描二维码生成的令牌
// console.log(`[DEBUG] OTPAUTH URI token detected`);
break;
}
const period = token.period || 30;
const remaining = getRemainingSeconds(period);
const prevRemaining = currentRemainingSeconds[token.id];
// console.log(`[DEBUG] Token ${token.id}: prev=${prevRemaining}, curr=${remaining}`);
// 更新剩余时间
remainingSeconds[token.id] = remaining;
// 在以下情况下更新token
// 1. 剩余时间为0新的时间窗口开始
// 2. 之前没有剩余时间记录(首次加载)
// 3. 时间窗口发生变化prevRemaining > remaining说明跨越了时间窗口
if (remaining === 0 || prevRemaining === undefined || (prevRemaining > 0 && remaining < prevRemaining)) {
// console.log(`[DEBUG] Token ${token.id} needs update`);
tokensToUpdate.push({...token}); // 创建token的副本
}
});
// 只在有变化时更新剩余时间
if (Object.keys(remainingSeconds).length > 0) {
// console.log('[DEBUG] Updating remainingSeconds:', remainingSeconds);
this.setData({
'remainingSeconds': {
...currentRemainingSeconds,
...remainingSeconds
}
}, () => {
// console.log('[DEBUG] remainingSeconds updated');
});
}
// 只更新需要更新的token并传入当前时间戳
if (tokensToUpdate.length > 0) {
// console.log('[DEBUG] Updating tokens:', tokensToUpdate.length);
const timestamp = Math.floor(Date.now() / 1000);
this.updateSelectedTokens(tokensToUpdate, timestamp);
}
}, 1000);
},
// 只更新选定的token
async updateSelectedTokens(tokensToUpdate, timestamp) {
try {
const currentTokens = this.data.tokens;
// 获取当前时间戳,如果没有传入
const currentTimestamp = timestamp || Math.floor(Date.now() / 1000);
// 为每个token计算正确的时间戳
const updatePromises = tokensToUpdate.map(token => {
if (token.type === 'totp') {
// 计算时间窗口的开始时间
const period = token.period || 30;
const windowStart = Math.floor(currentTimestamp / period) * period;
return this.updateTokenCode(token, windowStart);
} else {
// 对于HOTP类型直接使用当前时间戳
return this.updateTokenCode(token, currentTimestamp);
}
});
const updatedTokens = await Promise.all(updatePromises);
// 创建token ID到更新后token的映射
const updatedTokenMap = {};
updatedTokens.forEach(token => {
updatedTokenMap[token.id] = token;
});
// 更新tokens数组中的特定token
const newTokens = currentTokens.map(token =>
updatedTokenMap[token.id] ? updatedTokenMap[token.id] : token
);
this.setData({ tokens: newTokens });
} catch (error) {
console.error('更新选定token失败:', error);
}
},
async updateTokenCodes(tokens) {
if (!Array.isArray(tokens) || tokens.length === 0) {
return [];
}
try {
// 获取当前时间戳
const currentTimestamp = Math.floor(Date.now() / 1000);
// 并行更新所有令牌的验证码,为每个令牌计算其时间窗口的开始时间
const updatePromises = tokens.map(token => {
if (token.type === 'totp') {
// 计算时间窗口的开始时间
const period = token.period || 30;
const windowStart = Math.floor(currentTimestamp / period) * period;
return this.updateTokenCode(token, windowStart);
} else {
// 对于HOTP类型直接使用当前时间戳
return this.updateTokenCode(token, currentTimestamp);
}
});
const updatedTokens = await Promise.all(updatePromises);
return updatedTokens;
} catch (error) {
console.error('更新验证码失败:', error);
// 出错时返回原始tokens避免undefined
return tokens;
}
},
async updateTokenCode(token, timestamp) {
try {
// 生成新的验证码,使用传入的统一时间戳
const code = await generateCode({
type: token.type,
secret: token.secret,
algorithm: token.algorithm || 'SHA1',
digits: token.digits || 6,
period: token.period || 30,
counter: token.counter,
timestamp: timestamp // 使用传入的统一时间戳
});
// 更新令牌数据
token.code = code;
token.lastUpdate = formatTime(new Date());
return token;
} catch (error) {
console.error(`更新令牌 ${token.id} 失败:`, error);
token.error = error.message;
return token;
}
},
async refreshHotpToken(event) {
const tokenId = event.currentTarget.dataset.tokenId;
const token = this.data.tokens.find(t => t.id === tokenId);
if (!token) return;
try {
// 增加计数器值
const newCounter = (token.counter || 0) + 1;
// 生成新的验证码
const code = await generateCode({
type: 'hotp',
secret: token.secret,
algorithm: token.algorithm || 'SHA1',
counter: newCounter,
digits: token.digits || 6
});
// 更新令牌数据
token.code = code;
token.counter = newCounter;
token.lastUpdate = formatTime(new Date());
// 更新存储和状态
const tokens = this.data.tokens.map(t =>
t.id === token.id ? token : t
);
await wx.setStorageSync('tokens', tokens);
this.setData({ tokens });
// 同步到云端
if (this.data.syncing) {
await syncTokens(tokens);
}
} catch (error) {
console.error(`刷新HOTP令牌 ${token.id} 失败:`, error);
this.setData({
error: '刷新HOTP令牌失败: ' + error.message
});
}
},
async syncWithCloud() {
try {
this.setData({ syncing: true, error: null });
// 获取本地令牌
const localTokens = this.data.tokens;
// 同步令牌
const syncedTokens = await syncTokens(localTokens);
// 更新所有令牌的验证码
await this.updateTokenCodes(syncedTokens);
// 更新存储和状态
await wx.setStorageSync('tokens', syncedTokens);
this.setData({
tokens: syncedTokens,
syncing: false
});
} catch (error) {
console.error('同步失败:', error);
this.setData({
error: '同步失败: ' + error.message,
syncing: false
});
}
},
onUnload: function() {
// 清除定时器
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
// 移除令牌更新事件监听
if (this.tokensUpdatedListener) {
eventManager.off('tokensUpdated', this.tokensUpdatedListener);
}
},
// 处理令牌点击事件
handleTokenTap(event) {
const tokenId = event.currentTarget.dataset.tokenId;
const token = this.data.tokens.find(t => t.id === tokenId);
if (!token) return;
// 复制验证码到剪贴板
wx.setClipboardData({
data: token.code,
success: () => {
wx.showToast({
title: '验证码已复制',
icon: 'success',
duration: 1500
});
}
});
},
// 显示添加令牌菜单
showAddTokenMenu() {
wx.showActionSheet({
itemList: ['扫描二维码', '手动添加'],
success: (res) => {
switch (res.tapIndex) {
case 0: // 扫描二维码
wx.scanCode({
onlyFromCamera: false,
scanType: ['qrCode'],
success: (res) => {
try {
// 解析二维码内容
const qrContent = res.result;
// 如果是otpauth://格式的URL
if (qrContent.startsWith('otpauth://')) {
// 小程序兼容的URL解析
const [protocolAndPath, search] = qrContent.split('?');
const [protocol, path] = protocolAndPath.split('://');
const type = protocol.replace('otpauth:', '');
// 解析路径部分
const decodedPath = decodeURIComponent(path.substring(1)); // 移除开头的/
let [issuer, remark] = decodedPath.split(':');
if (!remark) {
remark = issuer;
issuer = '';
}
// 解析查询参数
const params = {};
if (search) {
search.split('&').forEach(pair => {
const [key, value] = pair.split('=');
if (key && value) {
params[key] = decodeURIComponent(value);
}
});
}
// 从参数中获取issuer如果存在
if (params.issuer) {
issuer = params.issuer;
}
// 验证secret参数
if (!params.secret) {
wx.showToast({
title: '无效的二维码缺少secret参数',
icon: 'none',
duration: 2000
});
return;
}
// 将otpauth类型转换为实际类型
let validType = type.toLowerCase();
if (validType === 'otpauth') {
// 从URI路径中提取实际类型
validType = path.split('/')[0].toLowerCase();
}
// 构建表单数据,确保数字类型参数正确转换
const formData = {
type: validType,
issuer,
remark,
secret: params.secret,
algorithm: params.algorithm || 'SHA1',
digits: params.digits ? parseInt(params.digits, 10) : 6,
period: validType === 'totp' ? (params.period ? parseInt(params.period, 10) : 30) : undefined,
counter: validType === 'hotp' ? (params.counter ? parseInt(params.counter, 10) : 0) : undefined
};
// 验证必要参数
if (formData.digits < 6 || formData.digits > 8) {
formData.digits = 6;
console.warn('验证码位数无效已设置为默认值6');
}
if (validType === 'totp' && (formData.period < 15 || formData.period > 60)) {
formData.period = 30;
console.warn('TOTP周期无效已设置为默认值30秒');
}
// 检查必要参数是否完整
if (formData.secret && formData.type) {
// 直接添加令牌
this.addTokenDirectly(formData).then(() => {
wx.showToast({
title: '令牌已添加',
icon: 'success'
});
}).catch(error => {
console.error('添加令牌失败:', error);
// 失败后跳转到表单页面
const queryString = Object.entries(formData)
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
wx.navigateTo({
url: `${this.pageRoutes.form}?${queryString}`
});
});
} else {
// 参数不完整,跳转到表单页面
const queryString = Object.entries(formData)
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
wx.navigateTo({
url: `${this.pageRoutes.form}?${queryString}`
});
}
} else {
wx.showToast({
title: '无效的二维码格式',
icon: 'error'
});
}
} catch (error) {
console.error('解析二维码失败:', error);
wx.showToast({
title: '解析二维码失败',
icon: 'error'
});
}
},
fail: (error) => {
console.error('扫描二维码失败:', error);
if (error.errMsg.includes('cancel')) {
// 用户取消扫码,不显示错误提示
return;
}
wx.showToast({
title: '扫描失败',
icon: 'error'
});
}
});
break;
case 1: // 手动添加
wx.navigateTo({
url: '/pages/form/form',
events: {
// 监听form页面的事件
tokenAdded: () => {
this.loadTokens();
}
},
success: () => {
// 跳转成功后的回调
wx.showToast({
title: '请填写令牌信息',
icon: 'none',
duration: 1000
});
}
});
break;
}
}
});
},
// 编辑令牌
editToken(event) {
const id = event.currentTarget.dataset.tokenId;
wx.navigateTo({
url: `${this.pageRoutes.edit}?token_id=${id}`
});
},
// 删除令牌
deleteToken(event) {
const id = event.currentTarget.dataset.id;
const that = this;
wx.showModal({
title: '确认删除',
content: '确定要删除这个令牌吗?',
success(res) {
if (res.confirm) {
// 从本地存储中删除
wx.getStorage({
key: 'tokens',
success(res) {
const tokens = res.data.filter(token => token.id !== id);
wx.setStorage({
key: 'tokens',
data: tokens,
success() {
// 更新UI
that.setData({ tokens });
wx.showToast({
title: '删除成功',
icon: 'success'
});
// 如果正在同步,更新云端
if (that.data.syncing) {
syncTokens(tokens).catch(err => {
console.error('同步删除失败:', err);
});
}
},
fail() {
wx.showToast({
title: '删除失败',
icon: 'none'
});
}
});
}
});
}
}
});
},
// 直接添加令牌
async addTokenDirectly(tokenData) {
try {
// 获取当前令牌列表
const tokens = await wx.getStorageSync('tokens') || [];
// 生成唯一ID和时间戳
const newToken = {
...tokenData,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
lastUpdate: new Date().toISOString()
};
// 如果是TOTP类型先初始化剩余时间
if ((newToken.type || 'totp').toLowerCase() === 'totp') {
const period = parseInt(newToken.period || '30', 10);
const currentRemainingSeconds = this.data.remainingSeconds || {};
// 先更新剩余时间,避免闪烁
this.setData({
remainingSeconds: {
...currentRemainingSeconds,
[newToken.id]: getRemainingSeconds(period)
}
});
}
// 生成验证码
try {
// 确保类型是字符串且为小写
const type = (newToken.type || 'totp').toLowerCase();
// 确保digits是数字
const digits = parseInt(newToken.digits || '6', 10);
// 确保period是数字
const period = parseInt(newToken.period || '30', 10);
// 确保counter是数字如果存在
const counter = newToken.counter ? parseInt(newToken.counter, 10) : undefined;
const code = await generateCode({
type,
secret: newToken.secret,
algorithm: newToken.algorithm || 'SHA1',
digits,
period,
counter
});
newToken.code = code;
} catch (error) {
console.error('生成验证码失败:', error);
wx.showToast({
title: '生成验证码失败: ' + error.message,
icon: 'none',
duration: 2000
});
throw error;
}
// 添加到列表并保存
const updatedTokens = [...tokens, newToken];
await wx.setStorageSync('tokens', updatedTokens);
// 更新UI使用setData的回调确保UI更新完成
this.setData({ tokens: updatedTokens }, () => {
// 触发更新事件
eventManager.emit('tokensUpdated', updatedTokens);
// 如果正在同步,更新云端
if (this.data.syncing) {
syncTokens(updatedTokens).catch(err => {
console.error('同步新令牌失败:', err);
});
}
});
} catch (error) {
console.error('直接添加令牌失败:', error);
throw error;
}
},
// 阻止触摸移动
catchTouchMove() {
return false;
}
});

1
pages/index/index.json Normal file
View file

@ -0,0 +1 @@
{}

108
pages/index/index.wxml Normal file
View file

@ -0,0 +1,108 @@
<view class="container {{loading ? 'is-loading' : ''}}">
<!-- 加载状态 -->
<view class="loading-container" wx:if="{{loading}}">
<view class="loading"></view>
</view>
<!-- 错误提示 -->
<view class="error-message" wx:if="{{error}}">{{error}}</view>
<!-- 令牌列表 -->
<scroll-view
class="scroll-view"
scroll-y="true"
enable-flex="true"
catch:touchmove="{{loading ? 'catchTouchMove' : ''}}"
>
<!-- 空状态 -->
<view class="empty-state" wx:if="{{!loading && tokens.length === 0}}">
<text class="empty-icon">🔐</text>
<text class="empty-title">暂无验证器</text>
<text class="empty-desc">点击下方按钮添加新的验证器</text>
</view>
<!-- 令牌列表 -->
<view class="token-list" wx:else>
<view
class="token-item {{token.type === 'totp' && remainingSeconds[token.id] <= 5 ? 'warn' : ''}}"
wx:for="{{tokens}}"
wx:key="id"
wx:for-item="token"
bindtap="handleTokenTap"
data-token-id="{{token.id}}"
>
<view class="token-content">
<!-- 令牌头部 -->
<view class="token-header">
<text class="token-type {{token.type}}">{{token.type === 'totp' ? 'TOTP' : 'HOTP'}}</text>
<text class="token-issuer">{{token.issuer || '未知服务'}}</text>
</view>
<!-- 令牌主体 -->
<view class="token-body">
<view class="code-container">
<text class="code">{{token.code || '------'}}</text>
<view class="code-actions">
<!-- HOTP刷新按钮 -->
<view
wx:if="{{token.type === 'hotp'}}"
class="refresh-btn {{token.refreshing ? 'refreshing' : ''}}"
catch:tap="refreshHotpToken"
data-token-id="{{token.id}}"
hover-class="button-hover"
>
<text class="refresh-icon {{token.refreshing ? 'spin' : ''}}">🔄</text>
</view>
<!-- 编辑按钮 -->
<view
class="edit-btn"
catch:tap="editToken"
data-token-id="{{token.id}}"
hover-class="button-hover"
>
<text class="edit-icon">✏️</text>
</view>
<!-- 删除按钮 -->
<view
class="delete-btn"
catch:tap="deleteToken"
data-id="{{token.id}}"
hover-class="button-hover"
>
<text class="delete-icon">🗑️</text>
</view>
</view>
</view>
<!-- 令牌信息 -->
<view class="token-info">
<!-- TOTP信息 -->
<view class="totp-info {{remainingSeconds[token.id] <= 5 ? 'warn' : ''}}" wx:if="{{token.type === 'totp'}}">
<text class="remaining-time">剩余 {{remainingSeconds[token.id]}} 秒</text>
<view class="progress-bar {{remainingSeconds[token.id] <= 5 ? 'warn' : ''}}">
<view class="progress" style="width: {{(remainingSeconds[token.id] / (token.period || 30)) * 100}}%"></view>
</view>
</view>
<!-- HOTP信息 -->
<view class="counter-info" wx:if="{{token.type === 'hotp'}}">
<text>计数器: {{token.counter || 0}}</text>
<text class="hint">点击刷新按钮生成新的验证码</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 广告容器 -->
<view class="ad-container" wx:if="{{!loading && tokens.length > 0}}">
<!-- 这里可以放广告组件 -->
</view>
</scroll-view>
<!-- 添加按钮 -->
<view class="add-button" bindtap="showAddTokenMenu" hover-class="button-hover">
<text class="add-icon">+</text>
<text class="add-text">添加验证器</text>
</view>
</view>

301
pages/index/index.wxss Normal file
View file

@ -0,0 +1,301 @@
/**index.wxss**/
.container {
padding: 20rpx;
box-sizing: border-box;
min-height: 100vh;
background: #f8f9fa;
}
.container.is-loading {
overflow: hidden;
}
/* 加载状态 */
.loading-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.loading {
width: 100%;
text-align: center;
padding: 40rpx 0;
color: #666;
}
/* 错误提示 */
.error-message {
width: 100%;
padding: 20rpx;
background-color: #ffebee;
color: #c62828;
text-align: center;
margin-bottom: 20rpx;
border-radius: 8rpx;
font-size: 28rpx;
}
/* 滚动视图 */
.scroll-view {
width: 100%;
height: calc(100vh - 40rpx);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
text-align: center;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-title {
font-size: 36rpx;
color: #333;
margin-bottom: 12rpx;
font-weight: 500;
}
.empty-desc {
font-size: 28rpx;
color: #999;
}
/* 令牌列表 */
.token-list {
width: 100%;
padding-bottom: 180rpx;
}
.token-item {
background: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
position: relative;
transition: all 0.3s ease;
}
.token-item.warn {
background: #fff8f8;
}
.token-content {
width: 100%;
}
/* 令牌头部 */
.token-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.token-type {
font-size: 24rpx;
padding: 4rpx 12rpx;
border-radius: 4rpx;
margin-right: 12rpx;
}
.token-type.totp {
background: #e3f2fd;
color: #1976d2;
}
.token-type.hotp {
background: #f3e5f5;
color: #7b1fa2;
}
.token-issuer {
font-size: 32rpx;
color: #333;
font-weight: 500;
}
/* 令牌主体 */
.token-body {
width: 100%;
}
.code-container {
display: flex;
align-items: center;
justify-content: space-between;
margin: 20rpx 0;
}
.code {
font-size: 48rpx;
font-family: monospace;
color: #007AFF;
font-weight: bold;
letter-spacing: 2rpx;
}
.code-actions {
display: flex;
align-items: center;
}
/* HOTP刷新按钮 */
.refresh-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
opacity: 0.6;
transition: all 0.3s ease;
}
.refresh-btn.refreshing {
opacity: 0.3;
pointer-events: none;
}
.refresh-icon {
font-size: 36rpx;
}
.refresh-icon.spin {
animation: spin 1s linear infinite;
}
/* 编辑按钮 */
.edit-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
}
.edit-icon {
font-size: 32rpx;
}
/* 删除按钮 */
.delete-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
margin-left: 12rpx;
}
.delete-icon {
font-size: 32rpx;
color: #ff4d4f;
}
.delete-btn.button-hover {
opacity: 0.8;
}
/* TOTP进度条 */
.totp-info {
margin-top: 16rpx;
}
.remaining-time {
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.progress-bar {
width: 100%;
height: 4rpx;
background: #e0e0e0;
border-radius: 2rpx;
overflow: hidden;
}
.progress-bar .progress {
height: 100%;
background: #4caf50;
transition: width 1s linear;
}
.progress-bar.warn .progress {
background: #f44336;
}
/* HOTP计数器信息 */
.counter-info {
margin-top: 16rpx;
font-size: 24rpx;
color: #666;
}
.counter-info .hint {
margin-left: 12rpx;
color: #999;
}
/* 添加按钮 */
.add-button {
position: fixed;
bottom: 40rpx;
left: 50%;
transform: translateX(-50%);
background: #007AFF;
border-radius: 40rpx;
padding: 20rpx 32rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 122, 255, 0.3);
transition: all 0.3s ease;
}
.add-icon {
color: white;
font-size: 40rpx;
margin-right: 8rpx;
}
.add-text {
color: white;
font-size: 28rpx;
}
/* 广告容器 */
.ad-container {
width: 100%;
margin-top: 20rpx;
margin-bottom: 20rpx;
}
/* 动画 */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 按钮点击效果 */
.button-hover {
opacity: 0.7;
transform: scale(0.98);
}

66
pages/info/info.js Normal file
View file

@ -0,0 +1,66 @@
// pages/info/info.js
Page({
/**
* 页面的初始数据
*/
data: {
},
/**
* 生命周期函数--监听页面加载
*/
onLoad: function (options) {
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady: function () {
},
/**
* 生命周期函数--监听页面显示
*/
onShow: function () {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide: function () {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload: function () {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh: function () {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom: function () {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage: function () {
}
})

3
pages/info/info.json Normal file
View file

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "介绍"
}

20
pages/info/info.wxml Normal file
View file

@ -0,0 +1,20 @@
<view class="container">
<view class="info-card">
<view class="info-item">
<text class="label">个人主页:</text>
<navigator url="https://www.xhupo.com" class="link">xhupo.com</navigator>
</view>
<view class="info-item">
<text class="label">评论博客:</text>
<navigator url="https://memos.zeroc.net" class="link">memos.zeroc.net</navigator>
</view>
<view class="info-item">
<text class="label">开发工具:</text>
<text>VSCode + 腾讯云CodeBuddy</text>
</view>
<view class="info-item">
<text class="label">项目源码:</text>
<navigator url="https://git.xhupo.com/otp" class="link">git.xhupo.com/otp</navigator>
</view>
</view>
</view>

31
pages/info/info.wxss Normal file
View file

@ -0,0 +1,31 @@
.container {
padding: 20px;
display: flex;
justify-content: center;
}
.info-card {
width: 100%;
max-width: 500px;
background: #fff;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.info-item {
margin-bottom: 15px;
display: flex;
align-items: center;
}
.label {
font-weight: bold;
width: 80px;
color: #333;
}
.link {
color: #07C160;
text-decoration: none;
}

217
pages/mine/mine.js Normal file
View file

@ -0,0 +1,217 @@
// pages/mine/mine.js
const {
syncTokens,
getCloudTokens,
showToast,
showLoading,
hideLoading
} = require('../../utils/util');
Page({
/**
* 页面的初始数据
*/
data: {
loading: false,
uploading: false,
isLoggedIn: false,
userInfo: null,
currentYear: new Date().getFullYear()
},
onShow: function() {
// 每次显示页面时检查登录状态
const app = getApp();
this.setData({
isLoggedIn: !!app.globalData.token,
userInfo: app.globalData.userInfo
});
},
/**
* 数据备份
*/
uploadData: async function() {
// 检查登录状态
if (!this.data.isLoggedIn) {
showToast('请先登录');
return;
}
try {
const tokens = wx.getStorageSync('tokens') || [];
if (!tokens.length) {
showToast('未发现本地数据');
return;
}
// 显示确认对话框
const confirmed = await new Promise(resolve => {
wx.showModal({
title: '数据备份',
content: '确定备份本地数据到云端?',
confirmText: '确定',
confirmColor: '#ff9c10',
success: res => resolve(res.confirm)
});
});
if (!confirmed) return;
this.setData({ uploading: true });
showLoading('正在备份...');
// 同步到云端
await syncTokens(tokens);
showToast('数据备份成功', 'success');
} catch (error) {
console.error('数据备份失败:', error);
showToast('数据备份失败,请重试');
} finally {
this.setData({ uploading: false });
hideLoading();
}
},
/**
* 数据恢复
*/
restoreData: async function() {
// 检查登录状态
if (!this.data.isLoggedIn) {
showToast('请先登录');
return;
}
try {
this.setData({ loading: true });
showLoading('正在获取云端数据...');
// 获取云端数据
const cloudTokens = await getCloudTokens();
if (!cloudTokens || !cloudTokens.length) {
showToast('未发现备份数据');
return;
}
// 显示确认对话框
const confirmed = await new Promise(resolve => {
wx.showModal({
title: '云端数据恢复',
content: `发现${cloudTokens.length}条数据,确定使用云端数据覆盖本地记录?`,
confirmColor: '#ff9c10',
success: res => resolve(res.confirm)
});
});
if (!confirmed) return;
// 保存到本地
await wx.setStorageSync('tokens', cloudTokens);
showToast('数据恢复成功', 'success');
} catch (error) {
console.error('数据恢复失败:', error);
showToast('数据恢复失败,请重试');
} finally {
this.setData({ loading: false });
hideLoading();
}
},
/**
* 用户授权登录
*/
goAuth: async function() {
if (this.data.isLoggedIn) return;
try {
showLoading('登录中...');
const app = getApp();
await app.userLogin();
// 更新登录状态,使用默认值
this.setData({
isLoggedIn: !!app.globalData.token,
userInfo: {
avatarUrl: wx.getStorageSync('userAvatar') || '/images/default-avatar.png',
nickName: wx.getStorageSync('userNickName') || '微信用户'
}
});
showToast('登录成功', 'success');
} catch (error) {
console.error('登录失败:', error);
showToast('登录失败,请重试');
} finally {
hideLoading();
}
},
/**
* 处理头像选择
*/
onChooseAvatar: async function(e) {
const { avatarUrl } = e.detail;
try {
showLoading('更新头像中...');
// 更新头像
this.setData({
'userInfo.avatarUrl': avatarUrl
});
// 保存到本地存储
wx.setStorageSync('userAvatar', avatarUrl);
showToast('头像更新成功', 'success');
} catch (error) {
console.error('头像更新失败:', error);
showToast('头像更新失败,请重试');
} finally {
hideLoading();
}
},
/**
* 处理昵称修改
*/
onNicknameChange: function(e) {
const nickName = e.detail.value;
if (!nickName) return;
try {
// 更新昵称
this.setData({
'userInfo.nickName': nickName
});
// 保存到本地存储
wx.setStorageSync('userNickName', nickName);
} catch (error) {
console.error('昵称更新失败:', error);
showToast('昵称更新失败,请重试');
}
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh: function() {
wx.stopPullDownRefresh();
},
/**
* 转发
*/
onShareAppMessage: function() {
return {
title: '支持云端备份的动态验证码',
path: '/pages/index/index',
imageUrl: '/images/share.png'
};
}
});

5
pages/mine/mine.json Normal file
View file

@ -0,0 +1,5 @@
{
"navigationStyle": "custom",
"enablePullDownRefresh": false,
"component": true
}

29
pages/mine/mine.wxml Normal file
View file

@ -0,0 +1,29 @@
<!--pages/mine/mine.wxml-->
<view class='mine' data-weui-theme="dark">
<view class="top-bg"></view>
<view class="user-bg">
<view class="userinfo">
<block wx:if="{{isLoggedIn && userInfo}}">
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
<image class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="aspectFit"></image>
</button>
<input type="nickname" class="userinfo-nickname" placeholder="请输入昵称" value="{{userInfo.nickName}}" bindchange="onNicknameChange"/>
</block>
<block wx:else>
<button class="login-btn" bindtap="goAuth">
<image class="userinfo-avatar" src="/images/default-avatar.png" mode="aspectFit"></image>
<text class="userinfo-nickname">点击登录</text>
</button>
</block>
</view>
</view>
<view class="btns">
<button class="btn" loading="{{uploading}}" disabled="{{uploading}}" bindtap="uploadData">数据备份</button>
<button class="btn" loading="{{loading}}" disabled="{{loading}}" bindtap="restoreData">数据恢复</button>
</view>
<view class="footer">
<navigator url="/pages/info/info" hover-class="none">
<view>Copyright © {{currentYear}}</view>
</navigator>
</view>
</view>

121
pages/mine/mine.wxss Normal file
View file

@ -0,0 +1,121 @@
/* pages/info/info.wxss */
page {
background: #f5f5f5;
}
.mine{
z-index: 0;
}
.top-bg{
z-index: -1;
position: fixed;
top:0;
width: 100%;
height: 550rpx;
background: #f8f9fa;
background-image: -webkit-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%);
background-image: -moz-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%);
background-image: -o-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%);
background-image: radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%);
}
.user-bg {
height: 550rpx;
display: flex;
justify-content: center;
padding-top: 40rpx;
overflow: hidden;
position: relative;
flex-direction: column;
align-items: center;
color: #333;
font-weight: 400;
text-shadow: 0 0 3px rgba(255, 255, 255, 0.3);
}
.userinfo {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 20rpx;
}
.userinfo-avatar {
width: 150rpx;
height: 150rpx;
border-radius: 50%;
overflow: hidden;
margin: 20rpx auto;
justify-content: center;
}
.userinfo-nickname {
color: #666;
text-align: center;
background-color: transparent;
}
.avatar-wrapper {
padding: 0;
width: 150rpx !important;
height: 150rpx !important;
border-radius: 50%;
background-color: transparent;
margin: 20rpx auto;
}
.avatar-wrapper::after {
border: none;
}
.login-btn {
display: flex;
flex-direction: column;
align-items: center;
background-color: transparent;
padding: 0;
margin: 0;
line-height: normal;
}
.login-btn::after {
border: none;
}
input.userinfo-nickname {
width: 200rpx;
border-bottom: 1px solid rgba(102, 102, 102, 0.5);
padding: 5rpx 10rpx;
margin-top: 10rpx;
}
.user-bg text {
opacity: 0.9;
}
.user-bg image {
width: 400rpx;
height: 200rpx;
}
.btns{
z-index: 10;
position: relative;
}
.btn{
z-index: 10;
margin: 1rem;
border-radius: 100px;
background: #ffffff;
color: #ff9c10;
border: 2px solid #e8e8e8;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.btn[disabled]:not([type]){
background: #ffffff;
color: #ff9c10;
}
.footer{
position: absolute;
bottom: 20px;
margin-top: 10rem;
font-size: 12px;
text-align: center;
width: 100%;
color: #999;
}

25
project.config.json Normal file
View file

@ -0,0 +1,25 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wxb6599459668b6b55",
"editorSetting": {}
}

View file

@ -0,0 +1,14 @@
{
"libVersion": "3.8.6",
"projectname": "%25E5%258A%25A8%25E6%2580%2581%25E4%25BB%25A4%25E7%2589%258C",
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true
}
}

7
sitemap.json Normal file
View file

@ -0,0 +1,7 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [{
"action": "allow",
"page": "*"
}]
}

1
utils/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__tests__

311
utils/auth.js Normal file
View file

@ -0,0 +1,311 @@
/**
* 认证相关工具函数
* 包含JWT token管理自动刷新请求拦截等功能
*/
const config = require('./config');
const eventManager = require('./eventManager');
// Token刷新状态
let isRefreshing = false;
// 等待token刷新的请求队列
let refreshSubscribers = [];
/**
* 订阅Token刷新
* @param {Function} callback - Token刷新后的回调函数
*/
const subscribeTokenRefresh = (callback) => {
refreshSubscribers.push(callback);
};
/**
* 执行Token刷新后的回调
* @param {string} token - 新的access token
*/
const onTokenRefreshed = (token) => {
refreshSubscribers.forEach(callback => callback(token));
refreshSubscribers = [];
};
/**
* 解析JWT Token
* @param {string} token - JWT token
* @returns {Object|null} 解析后的payload或null
*/
const parseToken = (token) => {
try {
const [, payload] = token.split('.');
return JSON.parse(atob(payload));
} catch (error) {
console.error('Token解析失败:', error);
return null;
}
};
/**
* 检查Token是否需要刷新
* @param {string} token - JWT token
* @returns {boolean} 是否需要刷新
*/
const shouldRefreshToken = (token) => {
const decoded = parseToken(token);
if (!decoded || !decoded.exp) return true;
const expiresIn = decoded.exp * 1000 - Date.now();
return expiresIn < config.JWT_CONFIG.refreshThreshold;
};
/**
* 刷新Token
* @returns {Promise<string|null>} 新的access token或null
*/
const refreshToken = async () => {
const refreshToken = wx.getStorageSync(config.JWT_CONFIG.storage.refresh);
if (!refreshToken) return null;
try {
const response = await wx.request({
url: `${config.API_BASE_URL}${config.API_ENDPOINTS.AUTH.REFRESH}`,
method: 'POST',
header: {
[config.JWT_CONFIG.headerKey]: `${config.JWT_CONFIG.tokenPrefix}${refreshToken}`
}
});
if (response.statusCode === 200 && response.data.access_token) {
const { access_token, refresh_token } = response.data;
wx.setStorageSync(config.JWT_CONFIG.storage.access, access_token);
if (refresh_token) {
wx.setStorageSync(config.JWT_CONFIG.storage.refresh, refresh_token);
}
return access_token;
}
throw new Error(response.data?.message || '刷新Token失败');
} catch (error) {
console.error('刷新Token失败:', error);
logout(); // 刷新失败时登出
return null;
}
};
/**
* 用户登录
* @param {string} username - 用户名
* @param {string} password - 密码
* @returns {Promise<Object>} 登录结果包含success和message字段
*/
const login = async (username, password) => {
try {
const response = await wx.request({
url: `${config.API_BASE_URL}${config.API_ENDPOINTS.AUTH.LOGIN}`,
method: 'POST',
data: { username, password }
});
if (response.statusCode === 200 && response.data.access_token) {
const { access_token, refresh_token } = response.data;
wx.setStorageSync(config.JWT_CONFIG.storage.access, access_token);
wx.setStorageSync(config.JWT_CONFIG.storage.refresh, refresh_token);
// 触发登录成功事件
eventManager.emit('auth:login', parseToken(access_token));
return {
success: true,
message: '登录成功'
};
}
return {
success: false,
message: response.data?.message || '登录失败'
};
} catch (error) {
console.error('登录失败:', error);
return {
success: false,
message: '网络错误,请稍后重试'
};
}
};
/**
* 用户登出
*/
const logout = () => {
// 清除所有认证信息
wx.removeStorageSync(config.JWT_CONFIG.storage.access);
wx.removeStorageSync(config.JWT_CONFIG.storage.refresh);
// 触发登出事件
eventManager.emit('auth:logout');
};
/**
* 检查用户是否已登录
* @returns {boolean} 是否已登录
*/
const isLoggedIn = () => {
const token = wx.getStorageSync(config.JWT_CONFIG.storage.access);
if (!token) return false;
try {
// 解析JWT token不验证签名
const [, payload] = token.split('.');
const { exp } = JSON.parse(atob(payload));
// 检查token是否已过期
return Date.now() < exp * 1000;
} catch (error) {
console.error('Token解析失败:', error);
return false;
}
};
/**
* 获取当前用户信息
* @returns {Object|null} 用户信息或null
*/
const getCurrentUser = () => {
const token = wx.getStorageSync(config.JWT_CONFIG.storage.access);
if (!token) return null;
try {
// 解析JWT token不验证签名
const [, payload] = token.split('.');
const decoded = JSON.parse(atob(payload));
return {
id: decoded.sub,
username: decoded.username,
// 其他用户信息...
};
} catch (error) {
console.error('获取用户信息失败:', error);
return null;
}
};
/**
* 获取访问令牌
* @param {boolean} autoRefresh - 是否自动刷新
* @returns {Promise<string|null>} 访问令牌或null
*/
const getAccessToken = async (autoRefresh = true) => {
const token = wx.getStorageSync(config.JWT_CONFIG.storage.access);
if (!token) return null;
// 检查是否需要刷新token
if (autoRefresh && shouldRefreshToken(token)) {
if (isRefreshing) {
// 如果正在刷新返回一个Promise等待刷新完成
return new Promise(resolve => {
subscribeTokenRefresh(newToken => {
resolve(newToken);
});
});
}
isRefreshing = true;
const newToken = await refreshToken();
isRefreshing = false;
if (newToken) {
onTokenRefreshed(newToken);
return newToken;
}
// 刷新失败清除token
logout();
return null;
}
return token;
};
/**
* 创建请求拦截器
* @returns {Function} 请求拦截器函数
*/
const createRequestInterceptor = () => {
const originalRequest = wx.request;
// 重写wx.request方法
wx.request = function(options) {
const { url, header = {}, ...restOptions } = options;
// 判断是否需要添加认证头
const isAuthUrl = url.includes(config.API_BASE_URL) &&
!url.includes(config.API_ENDPOINTS.AUTH.LOGIN);
if (isAuthUrl) {
// 创建一个Promise来处理认证
return new Promise(async (resolve, reject) => {
try {
const token = await getAccessToken();
if (!token) {
// 没有token且需要认证触发未授权事件
eventManager.emit('auth:unauthorized');
reject(new Error('未授权,请先登录'));
return;
}
// 添加认证头
const authHeader = {
...header,
[config.JWT_CONFIG.headerKey]: `${config.JWT_CONFIG.tokenPrefix}${token}`
};
// 发送请求
originalRequest({
...restOptions,
url,
header: authHeader,
success: resolve,
fail: reject
});
} catch (error) {
reject(error);
}
});
}
// 不需要认证的请求直接发送
return originalRequest(options);
};
return () => {
// 恢复原始请求方法
wx.request = originalRequest;
};
};
/**
* 初始化认证模块
*/
const initAuth = () => {
createRequestInterceptor();
// 监听网络状态变化
wx.onNetworkStatusChange(function(res) {
if (res.isConnected && isLoggedIn()) {
// 网络恢复且已登录尝试刷新token
getAccessToken();
}
});
console.log('认证模块初始化完成');
};
module.exports = {
login,
logout,
isLoggedIn,
getCurrentUser,
getAccessToken,
initAuth,
parseToken,
shouldRefreshToken
};

336
utils/base32.js Normal file
View file

@ -0,0 +1,336 @@
/**
* Enhanced Base32 implementation compatible with RFC 4648
* Optimized for WeChat Mini Program environment
*/
// RFC 4648 standard Base32 alphabet and padding
const DEFAULT_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const PADDING = '=';
// Pre-computed lookup tables
let ENCODE_TABLE = [...DEFAULT_ALPHABET];
let DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index]));
// Valid padding lengths for Base32
const VALID_PADDING_LENGTHS = new Set([0, 1, 3, 4, 6]);
/**
* Custom error class for Base32 operations
*/
class Base32Error extends Error {
constructor(message, type = 'GENERAL_ERROR') {
super(message);
this.name = 'Base32Error';
this.type = type;
}
}
/**
* String to UTF-8 bytes conversion (WeChat Mini Program compatible)
* @private
*/
function stringToUint8Array(str) {
const arr = [];
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code < 0x80) {
arr.push(code);
} else if (code < 0x800) {
arr.push(0xc0 | (code >> 6));
arr.push(0x80 | (code & 0x3f));
} else if (code < 0xd800 || code >= 0xe000) {
arr.push(0xe0 | (code >> 12));
arr.push(0x80 | ((code >> 6) & 0x3f));
arr.push(0x80 | (code & 0x3f));
} else {
// Handle surrogate pairs
i++;
code = 0x10000 + (((code & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
arr.push(0xf0 | (code >> 18));
arr.push(0x80 | ((code >> 12) & 0x3f));
arr.push(0x80 | ((code >> 6) & 0x3f));
arr.push(0x80 | (code & 0x3f));
}
}
return new Uint8Array(arr);
}
/**
* UTF-8 bytes to string conversion (WeChat Mini Program compatible)
* @private
*/
function uint8ArrayToString(bytes) {
let result = '';
for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i];
if (byte < 0x80) {
result += String.fromCharCode(byte);
} else if (byte < 0xe0) {
result += String.fromCharCode(
((byte & 0x1f) << 6) |
(bytes[++i] & 0x3f)
);
} else if (byte < 0xf0) {
result += String.fromCharCode(
((byte & 0x0f) << 12) |
((bytes[++i] & 0x3f) << 6) |
(bytes[++i] & 0x3f)
);
} else {
const codePoint = (
((byte & 0x07) << 18) |
((bytes[++i] & 0x3f) << 12) |
((bytes[++i] & 0x3f) << 6) |
(bytes[++i] & 0x3f)
);
result += String.fromCodePoint(codePoint);
}
}
return result;
}
/**
* Sets a custom alphabet for Base32 encoding/decoding
* @param {string} alphabet - The custom Base32 alphabet (32 unique characters)
* @throws {Base32Error} If the alphabet is invalid
*/
function setCustomAlphabet(alphabet) {
if (typeof alphabet !== 'string' || alphabet.length !== 32 || new Set(alphabet).size !== 32) {
throw new Base32Error(
'Invalid custom alphabet: must be 32 unique characters',
'INVALID_ALPHABET'
);
}
ENCODE_TABLE = [...alphabet];
DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index]));
}
/**
* Resets to the default Base32 alphabet
*/
function resetToDefaultAlphabet() {
ENCODE_TABLE = [...DEFAULT_ALPHABET];
DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index]));
}
/**
* Validates padding length and position
* @private
*/
function validatePadding(input) {
const paddingMatch = input.match(/=+$/);
if (!paddingMatch) return true;
const paddingLength = paddingMatch[0].length;
if (!VALID_PADDING_LENGTHS.has(paddingLength)) {
throw new Base32Error(
`Invalid padding length: ${paddingLength}`,
'INVALID_PADDING'
);
}
if (input.indexOf('=') !== input.length - paddingLength) {
throw new Base32Error(
'Padding character in invalid position',
'INVALID_PADDING_POSITION'
);
}
return true;
}
/**
* Encodes a string or Uint8Array to Base32
* @param {string|Uint8Array} input - The input to encode
* @param {Object} [options] - Encoding options
* @param {boolean} [options.noPadding=false] - Whether to omit padding
* @param {boolean} [options.strict=false] - Whether to enable strict mode
* @param {string} [options.alphabet] - Optional custom Base32 alphabet
* @returns {string} The Base32 encoded string
* @throws {Base32Error} If the input is invalid
*/
function encode(input, options = {}) {
try {
if (options.alphabet) {
setCustomAlphabet(options.alphabet);
}
const data = typeof input === 'string'
? stringToUint8Array(input)
: input;
if (!(data instanceof Uint8Array)) {
throw new Base32Error(
'Input must be a string or Uint8Array',
'INVALID_INPUT_TYPE'
);
}
let bits = 0;
let value = 0;
let output = '';
for (let i = 0; i < data.length; i++) {
value = (value << 8) | data[i];
bits += 8;
while (bits >= 5) {
output += ENCODE_TABLE[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += ENCODE_TABLE[(value << (5 - bits)) & 31];
}
if (!options.noPadding) {
const pad = 8 - (output.length % 8);
if (pad !== 8) {
output += PADDING.repeat(pad);
}
}
return output;
} catch (error) {
if (error instanceof Base32Error) throw error;
throw new Base32Error('Encoding failed: ' + error.message, 'ENCODING_ERROR');
} finally {
if (options.alphabet) {
resetToDefaultAlphabet();
}
}
}
/**
* Decodes a Base32 string to Uint8Array
* @param {string} input - The Base32 string to decode
* @param {Object} [options] - Decoding options
* @param {boolean} [options.strict=false] - Whether to enable strict mode
* @param {boolean} [options.returnString=false] - Whether to return a string instead of Uint8Array
* @param {string} [options.alphabet] - Optional custom Base32 alphabet
* @returns {Uint8Array|string} The decoded data
* @throws {Base32Error} If the input is invalid
*/
function decode(input, options = {}) {
try {
if (options.alphabet) {
setCustomAlphabet(options.alphabet);
}
if (typeof input !== 'string') {
throw new Base32Error('Input must be a string', 'INVALID_INPUT_TYPE');
}
const upperInput = input.toUpperCase();
if (options.strict) {
validatePadding(upperInput);
}
const cleanInput = upperInput.replace(/=+$/, '');
if (!/^[A-Z2-7]*$/.test(cleanInput)) {
throw new Base32Error('Invalid Base32 character', 'INVALID_CHARACTER');
}
let bits = 0;
let value = 0;
const output = [];
for (let i = 0; i < cleanInput.length; i++) {
const charValue = DECODE_TABLE.get(cleanInput[i]);
value = (value << 5) | charValue;
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
const result = new Uint8Array(output);
return options.returnString ? uint8ArrayToString(result) : result;
} catch (error) {
if (error instanceof Base32Error) throw error;
throw new Base32Error('Decoding failed: ' + error.message, 'DECODING_ERROR');
} finally {
if (options.alphabet) {
resetToDefaultAlphabet();
}
}
}
/**
* Validates if a string is a valid Base32 encoding
* @param {string} input - The string to validate
* @param {Object} [options] - Validation options
* @param {boolean} [options.strict=false] - Whether to enable strict mode
* @returns {boolean} True if the string is valid Base32
*/
function isValid(input, options = {}) {
try {
if (typeof input !== 'string') return false;
const upperInput = input.toUpperCase();
if (options.strict) {
validatePadding(upperInput);
}
const cleanInput = upperInput.replace(/=+$/, '');
return /^[A-Z2-7]*$/.test(cleanInput);
} catch {
return false;
}
}
/**
* Encodes a string as Base32 without padding
* @param {string|Uint8Array} input - The input to encode
* @returns {string} The Base32 encoded string without padding
*/
function encodeNoPadding(input) {
return encode(input, { noPadding: true });
}
/**
* Normalizes a Base32 string
* @param {string} input - The Base32 string to normalize
* @returns {string} The normalized Base32 string
*/
function normalize(input) {
if (!isValid(input)) {
throw new Base32Error('Invalid Base32 string', 'INVALID_INPUT');
}
const base = input.toUpperCase().replace(/=+$/, '');
const padding = 8 - (base.length % 8);
return padding === 8 ? base : base + PADDING.repeat(padding);
}
/**
* Calculates the length of encoded Base32 string
* @param {number} inputLength - Length of input in bytes
* @param {Object} [options] - Options
* @param {boolean} [options.noPadding=false] - Whether padding will be omitted
* @returns {number} Length of Base32 encoded string
*/
function encodedLength(inputLength, options = {}) {
const baseLength = Math.ceil(inputLength * 8 / 5);
if (options.noPadding) return baseLength;
return Math.ceil(baseLength / 8) * 8;
}
module.exports = {
encode,
decode,
encodeNoPadding,
isValid,
normalize,
encodedLength,
Base32Error,
setCustomAlphabet,
resetToDefaultAlphabet
};

139
utils/base32.test.js Normal file
View file

@ -0,0 +1,139 @@
// Base32 Test Suite
const base32 = require('./base32.js');
// Test cases
const tests = [
// 1. Basic encoding/decoding
{
name: '基础编码/解码测试',
input: 'Hello, World!',
expected: 'JBSWY3DPFQQFO33SNRSCC==='
},
// 2. Empty string
{
name: '空字符串测试',
input: '',
expected: ''
},
// 3. Unicode characters
{
name: 'Unicode字符测试',
input: '你好,世界!',
expected: '4S4K3DZNEOZTG5TF'
},
// 4. Special characters
{
name: '特殊字符测试',
input: '!@#$%^&*()',
expected: 'IVLF4UKEJRGUU==='
},
// 5. Long string
{
name: '长字符串测试',
input: 'A'.repeat(100),
expected: 'IFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQ======'
}
];
// Custom alphabet test
const customAlphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUV';
// Run tests
console.log('=== Base32 Test Suite ===\n');
// 1. Standard Tests
console.log('1. Standard Tests:');
tests.forEach(test => {
try {
const encoded = base32.encode(test.input);
const decoded = new TextDecoder().decode(base32.decode(encoded));
const passed = encoded === test.expected && decoded === test.input;
console.log(`${test.name}:`);
console.log(` Input: ${test.input}`);
console.log(` Encoded: ${encoded}`);
console.log(` Decoded: ${decoded}`);
console.log(` Result: ${passed ? '✓ 通过' : '✗ 失败'}\n`);
} catch (e) {
console.log(` Error: ${e.message}\n`);
}
});
// 2. Custom Alphabet Tests
console.log('2. Custom Alphabet Tests:');
try {
base32.setCustomAlphabet(customAlphabet);
const input = 'Hello';
const encoded = base32.encode(input);
const decoded = new TextDecoder().decode(base32.decode(encoded));
console.log(`Custom alphabet encoding:`);
console.log(` Input: ${input}`);
console.log(` Encoded: ${encoded}`);
console.log(` Decoded: ${decoded}`);
console.log(` Result: ${decoded === input ? '✓ 通过' : '✗ 失败'}\n`);
} catch (e) {
console.log(` Error: ${e.message}\n`);
}
// 3. Error Handling Tests
console.log('3. Error Handling Tests:');
// Invalid input type
try {
base32.encode(null);
console.log('Invalid input type test: ✗ 失败 (应该抛出错误)');
} catch (e) {
console.log(`Invalid input type test: ✓ 通过 (${e.type})`);
}
// Invalid Base32 string
try {
base32.decode('!@#$');
console.log('Invalid Base32 string test: ✗ 失败 (应该抛出错误)');
} catch (e) {
console.log(`Invalid Base32 string test: ✓ 通过 (${e.type})`);
}
// Invalid padding
try {
base32.decode('JBSWY3DP=', { strict: true });
console.log('Invalid padding test: ✗ 失败 (应该抛出错误)');
} catch (e) {
console.log(`Invalid padding test: ✓ 通过 (${e.type})`);
}
// 4. Utility Function Tests
console.log('\n4. Utility Function Tests:');
// isValid
console.log('isValid tests:');
[
'JBSWY3DP',
'JBSWY3DP======',
'invalid!',
''
].forEach(input => {
console.log(` "${input}": ${base32.isValid(input)}`);
});
// normalize
console.log('\nnormalize tests:');
[
'jbswy3dp',
'JBSWY3DP',
'JBSWY3DP======'
].forEach(input => {
try {
console.log(` "${input}" -> "${base32.normalize(input)}"`);
} catch (e) {
console.log(` "${input}" Error: ${e.message}`);
}
});
// encodedLength
console.log('\nencodedLength tests:');
[5, 10, 15].forEach(len => {
console.log(` Input length ${len}:`);
console.log(` With padding: ${base32.encodedLength(len)}`);
console.log(` Without padding: ${base32.encodedLength(len, { noPadding: true })}`);
});

298
utils/cloud.js Normal file
View file

@ -0,0 +1,298 @@
/**
* 云服务相关工具函数
*/
// 导入统一配置
const config = require('./config');
/**
* 检查JWT token是否需要刷新
* @returns {boolean}
*/
const shouldRefreshToken = () => {
try {
const token = wx.getStorageSync(config.JWT_CONFIG.storage.access);
if (!token) return true;
// 解析JWT token不验证签名
const [, payload] = token.split('.');
const { exp } = JSON.parse(atob(payload));
const expirationTime = exp * 1000; // 转换为毫秒
// 如果token将在5分钟内过期则刷新
return Date.now() + config.JWT_CONFIG.refreshThreshold > expirationTime;
} catch (error) {
console.error('Token解析失败:', error);
return true;
}
};
/**
* 刷新JWT token
* @returns {Promise<void>}
*/
const refreshToken = async () => {
try {
const refreshToken = wx.getStorageSync(config.JWT_CONFIG.storage.refresh);
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await wx.request({
url: `${config.API_BASE_URL}${config.API_ENDPOINTS.AUTH.REFRESH}`,
method: 'POST',
header: {
'Authorization': `Bearer ${refreshToken}`
}
});
if (response.statusCode === 200 && response.data.access_token) {
wx.setStorageSync(config.JWT_CONFIG.storage.access, response.data.access_token);
if (response.data.refresh_token) {
wx.setStorageSync(config.JWT_CONFIG.storage.refresh, response.data.refresh_token);
}
} else {
throw new Error('Token refresh failed');
}
} catch (error) {
console.error('Token刷新失败:', error);
// 清除所有token强制用户重新登录
wx.removeStorageSync(config.JWT_CONFIG.storage.access);
wx.removeStorageSync(config.JWT_CONFIG.storage.refresh);
throw new Error('认证已过期,请重新登录');
}
};
/**
* 发送HTTP请求的通用函数
* @param {string} url - API URL
* @param {Object} options - 请求选项
* @returns {Promise<any>} 响应数据
*/
const request = async (url, options = {}) => {
// 检查并刷新token
if (shouldRefreshToken()) {
await refreshToken();
}
const accessToken = wx.getStorageSync(config.JWT_CONFIG.storage.access);
const defaultOptions = {
method: 'GET',
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
}
};
const requestOptions = {
...defaultOptions,
...options,
url: `${config.API_BASE_URL}${url}`
};
// 合并headers
requestOptions.header = {
...defaultOptions.header,
...options.header
};
try {
const response = await new Promise((resolve, reject) => {
wx.request({
...requestOptions,
success: resolve,
fail: reject
});
});
// 处理401错误token无效
if (response.statusCode === 401) {
// 尝试刷新token并重试请求
await refreshToken();
return request(url, options); // 递归调用使用新token重试
}
if (response.statusCode >= 200 && response.statusCode < 300) {
return response.data;
}
throw new Error(response.data?.message || '请求失败');
} catch (error) {
console.error('API请求失败:', {
endpoint: url,
method: requestOptions.method,
status: error.statusCode || 'N/A',
error: error.message
});
throw error;
}
};
/**
* 上传令牌数据到云端
* @param {Array} tokens - 令牌数据数组
* @returns {Promise<string>} 云端数据ID
*/
const uploadTokens = async (tokens) => {
if (!tokens || tokens.length === 0) {
throw new Error('没有可上传的数据');
}
try {
const response = await request(config.API_ENDPOINTS.OTP.SAVE, {
method: 'POST',
data: {
tokens,
timestamp: Date.now()
}
});
if (response.success) {
return response.data.id;
}
throw new Error(response.message || '上传失败');
} catch (error) {
console.error('上传令牌数据失败:', error);
throw error;
}
};
/**
* 从云端获取最新的令牌数据
* @returns {Promise<Object>} 云端数据对象
*/
const fetchLatestTokens = async () => {
try {
const response = await request(config.API_ENDPOINTS.OTP.RECOVER, {
method: 'POST',
data: {
timestamp: Date.now()
}
});
if (response.success && response.data?.tokens) {
return {
tokens: response.data.tokens,
timestamp: response.data.timestamp,
num: response.data.tokens.length
};
}
throw new Error(response.message || '未找到云端数据');
} catch (error) {
console.error('获取云端数据失败:', error);
throw error;
}
};
/**
* 初始化云服务
* 检查认证状态并尝试恢复会话
* @returns {Promise<boolean>} 初始化是否成功
*/
const initCloud = async () => {
try {
// 检查是否有有效的访问令牌
const accessToken = wx.getStorageSync(config.JWT_CONFIG.storage.access);
if (!accessToken) {
console.log('未找到访问令牌,需要登录');
return false;
}
// 验证令牌有效性
if (shouldRefreshToken()) {
await refreshToken();
}
return true;
} catch (error) {
console.warn('云服务初始化失败:', error);
// 清除所有认证信息
wx.removeStorageSync(config.JWT_CONFIG.storage.access);
wx.removeStorageSync(config.JWT_CONFIG.storage.refresh);
return false;
}
};
/**
* 比较本地和云端数据
* @param {Array} localTokens - 本地令牌数组
* @param {Array} cloudTokens - 云端令牌数组
* @returns {Object} 比较结果
*/
const compareTokens = (localTokens, cloudTokens) => {
const result = {
added: [],
removed: [],
modified: [],
unchanged: []
};
// 创建查找映射
const localMap = new Map(localTokens.map(t => [t.id, t]));
const cloudMap = new Map(cloudTokens.map(t => [t.id, t]));
// 查找添加和修改的令牌
cloudTokens.forEach(cloudToken => {
const localToken = localMap.get(cloudToken.id);
if (!localToken) {
result.added.push(cloudToken);
} else if (JSON.stringify(localToken) !== JSON.stringify(cloudToken)) {
result.modified.push(cloudToken);
} else {
result.unchanged.push(cloudToken);
}
});
// 查找删除的令牌
localTokens.forEach(localToken => {
if (!cloudMap.has(localToken.id)) {
result.removed.push(localToken);
}
});
return result;
};
/**
* 合并本地和云端数据
* @param {Array} localTokens - 本地令牌数组
* @param {Array} cloudTokens - 云端令牌数组
* @param {Object} options - 合并选项
* @param {boolean} [options.preferCloud=true] - 冲突时是否优先使用云端数据
* @returns {Array} 合并后的令牌数组
*/
const mergeTokens = (localTokens, cloudTokens, options = { preferCloud: true }) => {
const comparison = compareTokens(localTokens, cloudTokens);
const result = [...comparison.unchanged];
// 添加新令牌
result.push(...comparison.added);
// 处理修改的令牌
comparison.modified.forEach(cloudToken => {
if (options.preferCloud) {
result.push(cloudToken);
} else {
const localToken = localTokens.find(t => t.id === cloudToken.id);
result.push(localToken);
}
});
// 如果不优先使用云端数据,保留本地删除的令牌
if (!options.preferCloud) {
result.push(...comparison.removed);
}
return result;
};
module.exports = {
uploadTokens,
fetchLatestTokens,
compareTokens,
mergeTokens,
initCloud,
shouldRefreshToken,
refreshToken
};

42
utils/config.js Normal file
View file

@ -0,0 +1,42 @@
/**
* 统一配置中心
* 合并原 config.js configManager.js 的功能
*/
// 基础配置
const baseConfig = {
// 生产环境配置
API_BASE_URL: 'https://otpm.zeroc.net',
API_VERSION: 'v1',
// API端点配置 (统一使用微信登录端点)
API_ENDPOINTS: {
AUTH: {
LOGIN: '/auth/login', // 改回原登录端点
REFRESH: '/auth/refresh'
},
OTP: {
SAVE: '/otp/save',
RECOVER: '/otp/recover'
}
},
// JWT配置
JWT_CONFIG: {
storage: {
access: 'jwt_access_token',
refresh: 'jwt_refresh_token'
},
refreshThreshold: 5 * 60 * 1000, // Token过期前5分钟开始刷新
headerKey: 'Authorization',
tokenPrefix: 'Bearer '
}
};
// 导出合并后的配置
module.exports = {
...baseConfig.API_ENDPOINTS,
...baseConfig.JWT_CONFIG,
API_BASE_URL: baseConfig.API_BASE_URL,
API_VERSION: baseConfig.API_VERSION
};

165
utils/crypto.js Normal file
View file

@ -0,0 +1,165 @@
/**
* 加密工具函数
* 为微信小程序环境优化的加密工具集
*/
const { decode: base32Decode } = require('./base32.js');
/**
* 常量时间比较两个字符串
* 防止时间侧信道攻击
*
* @param {string} a - 第一个字符串
* @param {string} b - 第二个字符串
* @returns {boolean} 是否相等
*/
function constantTimeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') {
return false;
}
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
/**
* 安全的整数解析
* 带有范围检查
*
* @param {number|string} value - 要解析的值
* @param {number} min - 最小值包含
* @param {number} max - 最大值包含
* @returns {number|null} 解析后的整数如果无效则返回null
*/
function safeIntegerParse(value, min, max) {
let num;
if (typeof value === 'number') {
num = value;
} else if (typeof value === 'string') {
num = parseInt(value, 10);
} else {
return null;
}
if (!Number.isInteger(num)) {
return null;
}
if (num < min || num > max) {
return null;
}
return num;
}
/**
* 生成密码学安全的随机字节
*
* @param {number} length - 需要的字节数
* @returns {Uint8Array} 随机字节数组
*/
function getRandomBytes(length) {
if (!Number.isInteger(length) || length <= 0) {
throw new Error('Length must be a positive integer');
}
// 优先使用微信小程序的随机数API
if (typeof wx !== 'undefined' && wx.getRandomValues) {
const array = new Uint8Array(length);
wx.getRandomValues({
length: length,
success: (res) => {
array.set(new Uint8Array(res.randomValues));
},
fail: () => {
// 如果原生API失败回退到Math.random
for (let i = 0; i < length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
}
});
return array;
}
// 回退到Math.random不够安全但作为降级方案
const array = new Uint8Array(length);
for (let i = 0; i < length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
}
/**
* 生成指定长度的随机Base32密钥
*
* @param {number} length - 密钥长度字节
* @returns {string} Base32编码的密钥
*/
function generateSecretKey(length = 20) {
if (!Number.isInteger(length) || length < 16 || length > 64) {
throw new Error('Key length must be between 16 and 64 bytes');
}
const bytes = getRandomBytes(length);
return require('./base32.js').encode(bytes);
}
/**
* 验证Base32密钥的有效性和强度
*
* @param {string} key - Base32编码的密钥
* @returns {boolean} 密钥是否有效且足够强
*/
function validateBase32Secret(key) {
try {
// 检查是否是有效的Base32
if (!require('./base32.js').isValid(key)) {
return false;
}
// 解码密钥
const decoded = base32Decode(key);
// 检查最小长度128位/16字节
if (decoded.length < 16) {
return false;
}
// 检查是否全为0或1弱密钥
let allZeros = true;
let allOnes = true;
for (const byte of decoded) {
if (byte !== 0) allZeros = false;
if (byte !== 255) allOnes = false;
if (!allZeros && !allOnes) break;
}
if (allZeros || allOnes) {
return false;
}
return true;
} catch {
return false;
}
}
const { deriveKeyPBKDF2 } = require('./hmac.js');
module.exports = {
constantTimeEqual,
safeIntegerParse,
getRandomBytes,
generateSecretKey,
validateBase32Secret,
deriveKeyPBKDF2
};

24
utils/eventManager.js Normal file
View file

@ -0,0 +1,24 @@
// 事件管理器模块
const eventManager = {
listeners: {},
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
},
off(event, callback) {
const callbacks = this.listeners[event];
if (callbacks) {
this.listeners[event] = callbacks.filter(cb => cb !== callback);
}
},
emit(event, data) {
const callbacks = this.listeners[event];
if (callbacks) {
callbacks.forEach(callback => callback(data));
}
}
};
module.exports = eventManager;

131
utils/format.js Normal file
View file

@ -0,0 +1,131 @@
/**
* 格式化相关工具函数
*/
/**
* 格式化数字为两位数字符串
* @param {number} n 数字
* @returns {string} 格式化后的字符串
*/
const formatNumber = n => {
n = n.toString();
return n[1] ? n : '0' + n;
};
/**
* 格式化时间
* @param {Date} date 日期对象
* @returns {string} 格式化后的时间字符串
*/
const formatTime = date => {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const hour = date.getHours();
const minute = date.getMinutes();
const second = date.getSeconds();
return [year, month, day].map(formatNumber).join('/') + ' ' + [hour, minute, second].map(formatNumber).join(':');
};
/**
* 解析OTP URL格式
* @param {string} url - OTP URL字符串
* @returns {Object|null} 解析后的令牌数据对象如果解析失败返回null
*/
const parseURL = (url) => {
try {
// 基本格式验证
if (!url.startsWith('otpauth://')) {
return null;
}
// 解析URL
const urlObj = new URL(url);
const type = urlObj.hostname.toLowerCase();
const pathParts = urlObj.pathname.substring(1).split(':');
const issuer = decodeURIComponent(pathParts[0]);
const account = pathParts.length > 1 ? decodeURIComponent(pathParts[1]) : '';
// 解析查询参数
const params = {};
urlObj.searchParams.forEach((value, key) => {
params[key.toLowerCase()] = decodeURIComponent(value);
});
// 构建令牌数据
const tokenData = {
type: type,
issuer: issuer,
remark: account,
secret: params.secret || '',
algo: (params.algorithm || 'SHA1').toUpperCase(),
digits: parseInt(params.digits || '6', 10)
};
// 类型特定参数
if (type === 'totp') {
tokenData.period = parseInt(params.period || '30', 10);
} else if (type === 'hotp') {
tokenData.counter = parseInt(params.counter || '0', 10);
}
return tokenData;
} catch (error) {
console.error('[Error] Failed to parse OTP URL', {
error: error.message,
url: url.length > 100 ? url.substring(0, 100) + '...' : url
});
return null;
}
};
/**
* 验证令牌数据的有效性
* @param {Object} tokenData - 令牌数据对象
* @returns {string[]|null} 错误信息数组如果验证通过返回null
*/
const validateToken = (tokenData) => {
const errors = [];
// 验证必填字段
if (!tokenData.issuer || !tokenData.issuer.trim()) {
errors.push('服务名称不能为空');
}
if (!tokenData.secret || !tokenData.secret.trim()) {
errors.push('密钥不能为空');
}
// 验证算法
const validAlgos = ['SHA1', 'SHA256', 'SHA512'];
if (!validAlgos.includes(tokenData.algo)) {
errors.push(`不支持的算法: ${tokenData.algo}`);
}
// 验证位数
if (tokenData.digits < 6 || tokenData.digits > 8) {
errors.push('位数必须在6-8之间');
}
// 类型特定验证
if (tokenData.type === 'totp') {
if (tokenData.period < 15 || tokenData.period > 300) {
errors.push('更新周期必须在15-300秒之间');
}
} else if (tokenData.type === 'hotp') {
if (tokenData.counter < 0) {
errors.push('计数器值不能为负数');
}
} else {
errors.push('无效的令牌类型');
}
return errors.length > 0 ? errors : null;
};
module.exports = {
formatTime,
formatNumber,
parseURL,
validateToken
};

290
utils/hmac.js Normal file
View file

@ -0,0 +1,290 @@
/**
* HMACHash-based Message Authentication Code实现
* 优先使用微信小程序原生API如果不可用则使用sjcl库作为备用实现
*
* 依赖
* - sjcl: Stanford JavaScript Crypto Library
*/
import sjcl from './sjcl.min.js';
// 兼容 sjcl.codec.bytes
if (!sjcl.codec.bytes) {
sjcl.codec.bytes = {
fromBits: function (arr) {
var out = [], bl = sjcl.bitArray.bitLength(arr), i, tmp;
for (i = 0; i < bl / 8; i++) {
if ((i & 3) === 0) {
tmp = arr[i / 4] || 0;
}
out.push(tmp >>> 24);
tmp <<= 8;
}
return out;
},
toBits: function (bytes) {
var out = [], i, tmp = 0;
for (i = 0; i < bytes.length; i++) {
tmp = tmp << 8 | bytes[i];
if ((i & 3) === 3) {
out.push(tmp);
tmp = 0;
}
}
if (i & 3) {
out.push(sjcl.bitArray.partial(8 * (i & 3), tmp));
}
return out;
}
};
}
// 微信小程序原生 createHMAC 接口映射表
const HASH_ALGORITHMS = {
SHA1: 'sha1',
SHA256: 'sha256',
SHA512: 'sha512'
};
/**
* 生成 HMAC-SHA1/SHA256/SHA512
*
* @param {string} algorithm - 哈希算法 ('sha1', 'sha256', 'sha512')
* @param {string|Uint8Array} key - 密钥
* @param {string|Uint8Array} data - 数据
* @returns {Promise<Uint8Array>} HMAC 结果
* @throws {Error} 如果算法不支持或参数错误
*/
function hmac(algorithm, key, data) {
return new Promise((resolve, reject) => {
try {
// 增强参数校验
if (!algorithm || typeof algorithm !== 'string') {
throw new Error('Algorithm must be a non-empty string');
}
const normalizedAlgorithm = algorithm.toLowerCase();
if (!['sha1', 'sha256', 'sha512'].includes(normalizedAlgorithm)) {
throw new Error(`Unsupported algorithm: ${algorithm}. Supported: sha1, sha256, sha512`);
}
if (!key || (typeof key !== 'string' && !(key instanceof Uint8Array))) {
throw new Error('Key must be a non-empty string or Uint8Array');
}
if (!data || (typeof data !== 'string' && !(data instanceof Uint8Array))) {
throw new Error('Data must be a non-empty string or Uint8Array');
}
// 优先使用微信小程序原生接口
if (typeof wx !== 'undefined' && wx.createHMAC) {
try {
const crypto = wx.createHMAC(normalizedAlgorithm);
// 改进数据格式转换
const keyStr = typeof key === 'string' ? key :
new TextDecoder().decode(key);
const dataStr = typeof data === 'string' ? data :
new TextDecoder().decode(data);
crypto.update(keyStr);
crypto.update(dataStr);
const result = crypto.digest();
if (!result || result.byteLength === 0) {
throw new Error('HMAC digest returned empty result');
}
resolve(result);
} catch (wxError) {
console.error('WeChat HMAC API failed:', {
algorithm: normalizedAlgorithm,
error: wxError.message
});
throw new Error(`WeChat HMAC failed: ${wxError.message}`);
}
} else {
// 使用sjcl库作为备用实现
const keyBytes = typeof key === 'string' ? new TextEncoder().encode(key) : key;
const dataBytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
// 根据算法选择对应的哈希函数
let hashFunction;
switch (normalizedAlgorithm) {
case 'sha1':
hashFunction = sjcl.hash.sha1;
break;
case 'sha256':
hashFunction = sjcl.hash.sha256;
break;
case 'sha512':
hashFunction = sjcl.hash.sha512;
break;
default:
throw new Error(`Unsupported algorithm: ${algorithm}`);
}
const backupResult = backupHMAC(keyBytes, dataBytes, hashFunction);
resolve(backupResult);
}
} catch (error) {
console.error('HMAC generation failed:', error);
reject(new Error(`HMAC generation failed: ${error.message}`));
}
});
}
/**
* 备用HMAC实现使用sjcl库
*
* @param {Uint8Array} key - 密钥
* @param {Uint8Array} data - 数据
* @param {Function} hashFunction - 哈希算法构造函数
* @returns {Uint8Array} HMAC 结果
*/
function backupHMAC(key, data, hashFunction) {
try {
// 将key和data转换为sjcl的bitArray格式
const keyBits = sjcl.codec.bytes.toBits(Array.from(key));
const dataBits = sjcl.codec.bytes.toBits(Array.from(data));
// 创建HMAC对象并更新数据
const hmac = new sjcl.misc.hmac(keyBits, hashFunction);
hmac.update(dataBits);
// 获取结果并转换回字节数组
const result = hmac.digest();
const byteArray = sjcl.codec.bytes.fromBits(result);
return new Uint8Array(byteArray);
} catch (error) {
console.error('Backup HMAC implementation failed:', error);
throw new Error(`Backup HMAC implementation failed: ${error.message}`);
}
}
/**
* 生成 HMAC-SHA1 向后兼容
*
* @param {string|Uint8Array} key - 密钥
* @param {string|Uint8Array} data - 数据
* @returns {Promise<Uint8Array>} HMAC 结果
*/
function sha1(key, data) {
return hmac('sha1', key, data);
}
/**
* 生成 HMAC-SHA256
*
* @param {string|Uint8Array} key - 密钥
* @param {string|Uint8Array} data - 数据
* @returns {Promise<Uint8Array>} HMAC 结果
*/
function sha256(key, data) {
return hmac('sha256', key, data);
}
/**
* 生成 HMAC-SHA512
*
* @param {string|Uint8Array} key - 密钥
* @param {string|Uint8Array} data - 数据
* @returns {Promise<Uint8Array>} HMAC 结果
*/
function sha512(key, data) {
return hmac('sha512', key, data);
}
/**
* 使用PBKDF2算法派生密钥
*
* @param {Uint8Array} password - 原始密码
* @param {Uint8Array} salt - 盐值
* @param {number} iterations - 迭代次数建议至少10000次
* @param {number} keyLength - 生成的密钥长度字节
* @param {string} hashAlgorithm - 哈希算法'sha256'
* @returns {Promise<Uint8Array>} 派生出的密钥
*/
async function deriveKeyPBKDF2(password, salt, iterations, keyLength, hashAlgorithm = 'sha256') {
// 参数验证
if (!(password instanceof Uint8Array) || !(salt instanceof Uint8Array)) {
throw new Error('Password and salt must be Uint8Array');
}
if (typeof iterations !== 'number' || iterations <= 0) {
throw new Error('Iterations must be a positive number');
}
if (typeof keyLength !== 'number' || keyLength <= 0) {
throw new Error('Key length must be a positive number');
}
// 优先使用微信小程序原生加密API
if (typeof wx !== 'undefined' && wx.derivePBKDF2) {
return new Promise((resolve, reject) => {
wx.derivePBKDF2({
password: password.buffer,
salt: salt.buffer,
iterations: iterations,
keySize: keyLength,
hashAlgorithm: hashAlgorithm,
success: (res) => {
resolve(new Uint8Array(res.key));
},
fail: (err) => {
console.error('PBKDF2 derivation failed:', {
source: 'WeChat API',
error: err.errMsg || 'Unknown error'
});
reject(new Error(`PBKDF2 derivation failed: ${err.errMsg}`));
}
});
});
}
// 使用sjcl库的PBKDF2实现
try {
// 根据算法选择对应的哈希函数
let hashFunction;
switch (hashAlgorithm.toLowerCase()) {
case 'sha1':
hashFunction = sjcl.hash.sha1;
break;
case 'sha256':
hashFunction = sjcl.hash.sha256;
break;
case 'sha512':
hashFunction = sjcl.hash.sha512;
break;
default:
throw new Error(`Unsupported hash algorithm: ${hashAlgorithm}`);
}
// 将密码转换为字符串格式
const passwordStr = typeof password === 'string' ? password : String.fromCharCode.apply(null, password);
// 将盐值转换为字符串格式
const saltStr = typeof salt === 'string' ? salt : String.fromCharCode.apply(null, salt);
// 使用sjcl的PBKDF2实现
const key = sjcl.misc.pbkdf2(passwordStr, saltStr, iterations, keyLength * 8, hashFunction);
// 将结果转换为Uint8Array
const keyBuffer = new ArrayBuffer(keyLength);
const keyData = new Uint8Array(keyBuffer);
for (let i = 0; i < keyLength; i++) {
keyData[i] = key[i];
}
return keyData;
} catch (error) {
throw new Error(`PBKDF2 derivation failed: ${error.message}`);
}
}
// 导出函数
export {
hmac,
sha1,
sha256,
sha512,
deriveKeyPBKDF2
};

213
utils/hotp.js Normal file
View file

@ -0,0 +1,213 @@
const { hmac } = require('./hmac.js');
const { base32Decode } = require('./base32.js');
const { constantTimeEqual, safeIntegerParse } = require('./crypto.js');
// 支持的哈希算法
const HASH_ALGOS = {
'SHA1': 'SHA1',
'SHA256': 'SHA256',
'SHA512': 'SHA512'
};
// 默认配置
const DEFAULT_CONFIG = {
algorithm: HASH_ALGOS.SHA1, // 默认使用SHA1
digits: 6, // 默认6位数字
window: 1 // 默认验证前后1个计数值
};
/**
* 生成HOTP值
*
* @param {string} secret - Base32编码的密钥
* @param {number} counter - 计数器值
* @param {Object} options - 配置选项
* @param {string} [options.algorithm='SHA1'] - 使用的哈希算法SHA1/SHA256/SHA512
* @param {number} [options.digits=6] - 生成的OTP位数
* @returns {Promise<string>} 生成的HOTP值
* @throws {Error} 参数无效时抛出错误
*/
async function generateHOTP(secret, counter, options = {}) {
// 验证密钥
if (!secret || typeof secret !== 'string') {
throw new Error('Secret must be a non-empty string');
}
// 验证计数器
if (!Number.isInteger(counter) || counter < 0) {
throw new Error('Counter must be a non-negative integer');
}
// 合并配置
const config = { ...DEFAULT_CONFIG, ...options };
// 验证算法
if (!HASH_ALGOS[config.algorithm]) {
throw new Error(`Unsupported algorithm: ${config.algorithm}`);
}
// 验证位数
const digits = safeIntegerParse(config.digits, 6, 8);
if (digits === null) {
throw new Error('Digits must be an integer between 6 and 8');
}
try {
// 解码密钥
const key = base32Decode(secret);
// 生成8字节的计数器缓冲区
const counterBuffer = new ArrayBuffer(8);
const counterView = new DataView(counterBuffer);
counterView.setBigInt64(0, BigInt(counter), false); // big-endian
// 计算HMAC
const hash = await hmac(config.algorithm, key, new Uint8Array(counterBuffer));
// 根据RFC 4226获取偏移量
const offset = hash[hash.length - 1] & 0xf;
// 生成4字节的动态截断数
const binary = ((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
// 生成指定位数的OTP
const otp = binary % Math.pow(10, digits);
// 补齐前导零
return otp.toString().padStart(digits, '0');
} catch (error) {
throw new Error(`Failed to generate HOTP: ${error.message}`);
}
}
/**
* 验证HOTP值
*
* @param {string} token - 要验证的HOTP值
* @param {string} secret - Base32编码的密钥
* @param {number} counter - 当前计数器值
* @param {Object} options - 配置选项
* @param {string} [options.algorithm='SHA1'] - 使用的哈希算法SHA1/SHA256/SHA512
* @param {number} [options.digits=6] - HOTP位数
* @param {number} [options.window=1] - 验证窗口大小前后几个计数值
* @returns {Promise<number|false>} 如果验证通过返回匹配的计数器值否则返回false
* @throws {Error} 参数无效时抛出错误
*/
async function verifyHOTP(token, secret, counter, options = {}) {
// 验证token
if (!token || typeof token !== 'string') {
throw new Error('Token must be a non-empty string');
}
// 验证计数器
if (!Number.isInteger(counter) || counter < 0) {
throw new Error('Counter must be a non-negative integer');
}
// 合并配置
const config = { ...DEFAULT_CONFIG, ...options };
// 验证窗口大小
const window = safeIntegerParse(config.window, 0, 10);
if (window === null) {
throw new Error('Window must be an integer between 0 and 10');
}
// 验证token长度
if (token.length !== config.digits) {
return false;
}
// 验证token是否为纯数字
if (!/^\d+$/.test(token)) {
return false;
}
try {
// 检查前后window个计数值
for (let i = -window; i <= window; i++) {
const checkCounter = counter + i;
if (checkCounter < 0) continue;
const generatedToken = await generateHOTP(secret, checkCounter, config);
// 使用常量时间比较
if (constantTimeEqual(token, generatedToken)) {
return checkCounter;
}
}
return false;
} catch (error) {
throw new Error(`Failed to verify HOTP: ${error.message}`);
}
}
/**
* 生成HOTP URI
* 遵循 otpauth:// 规范
*
* @param {string} secret - Base32编码的密钥
* @param {string} accountName - 账户名称
* @param {string} issuer - 发行者名称
* @param {number} counter - 初始计数器值
* @param {Object} options - 配置选项
* @param {string} [options.algorithm='SHA1'] - 使用的哈希算法SHA1/SHA256/SHA512
* @param {number} [options.digits=6] - HOTP位数
* @returns {string} HOTP URI
* @throws {Error} 参数无效时抛出错误
*/
function generateHOTPUri(secret, accountName, issuer, counter, options = {}) {
if (!secret || typeof secret !== 'string') {
throw new Error('Secret must be a non-empty string');
}
if (!accountName || typeof accountName !== 'string') {
throw new Error('Account name must be a non-empty string');
}
if (!issuer || typeof issuer !== 'string') {
throw new Error('Issuer must be a non-empty string');
}
if (!Number.isInteger(counter) || counter < 0) {
throw new Error('Counter must be a non-negative integer');
}
const config = { ...DEFAULT_CONFIG, ...options };
// 验证算法
if (!HASH_ALGOS[config.algorithm]) {
throw new Error(`Unsupported algorithm: ${config.algorithm}`);
}
// 验证位数
const digits = safeIntegerParse(config.digits, 6, 8);
if (digits === null) {
throw new Error('Digits must be an integer between 6 and 8');
}
// 构建参数
const params = new URLSearchParams({
secret: secret,
issuer: issuer,
algorithm: config.algorithm,
digits: config.digits.toString(),
counter: counter.toString()
});
// 生成URI
// 注意需要对issuer和accountName进行URI编码
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`;
return `otpauth://hotp/${label}?${params.toString()}`;
}
module.exports = {
generateHOTP,
verifyHOTP,
generateHOTPUri,
HASH_ALGOS
};

150
utils/otp.js Normal file
View file

@ -0,0 +1,150 @@
/**
* OTP (One-Time Password) 工具集
* 包含TOTP和HOTP的实现以及相关辅助功能
*/
const { generateTOTP: baseTOTP, getRemainingSeconds: baseGetRemainingSeconds } = require('./totp');
const { generateHOTP } = require('./hotp');
const { parseURL } = require('./format');
/**
* 获取当前时间戳
* 确保在同一时间窗口内使用相同的时间戳
* @returns {number} 当前时间戳
*/
function getCurrentTimestamp() {
return Math.floor(Date.now() / 1000);
}
/**
* 生成OTP值
*
* @param {string} type - OTP类型'totp''hotp'
* @param {string} secret - Base32编码的密钥
* @param {Object} options - 配置选项
* @param {number} [options.counter] - 计数器值仅HOTP需要
* @param {number} [options.timestamp] - 用于TOTP的时间戳
* @param {boolean} [options._forceRefresh] - 是否强制刷新不使用缓存
* @returns {Promise<string>} 生成的OTP值
* @throws {Error} 参数无效时抛出错误
*/
async function generateOTP(type, secret, options = {}) {
// 处理otpauth URI
if (type === 'otpauth') {
const parsed = parseURL(secret);
if (!parsed) {
throw new Error('Invalid otpauth URI format');
}
// 使用解析出的类型和参数
return await generateOTP(parsed.type, parsed.secret, {
...options,
...(parsed.type === 'totp' ? { period: parsed.period } : {}),
...(parsed.type === 'hotp' ? { counter: parsed.counter } : {}),
algorithm: parsed.algo,
digits: parsed.digits
});
}
if (type === 'totp') {
const totpOptions = {
...options,
timestamp: options.timestamp || getCurrentTimestamp(),
_forceRefresh: !!options._forceRefresh
};
return await baseTOTP(secret, totpOptions);
} else if (type === 'hotp') {
if (options.counter === undefined) {
throw new Error('Counter is required for HOTP');
}
return await generateHOTP(secret, options.counter, options);
} else {
throw new Error(`Unsupported OTP type: ${type}`);
}
}
/**
* 验证OTP值
*
* @param {string} token - 要验证的OTP值
* @param {string} type - OTP类型'totp''hotp'
* @param {string} secret - Base32编码的密钥
* @param {Object} options - 配置选项
* @param {number} [options.counter] - 当前计数器值仅HOTP需要
* @returns {Promise<boolean>} 验证结果
* @throws {Error} 参数无效时抛出错误
*/
async function verifyOTP(token, type, secret, options = {}) {
// 处理otpauth URI
if (type === 'otpauth') {
const parsed = parseURL(secret);
if (!parsed) {
throw new Error('Invalid otpauth URI format');
}
// 使用解析出的类型和参数
return await verifyOTP(token, parsed.type, parsed.secret, {
...options,
...(parsed.type === 'totp' ? { period: parsed.period } : {}),
...(parsed.type === 'hotp' ? { counter: parsed.counter } : {}),
algorithm: parsed.algo,
digits: parsed.digits
});
}
if (type === 'totp') {
// 始终使用基础版本TOTP确保一致性
const generatedToken = await baseTOTP(secret, {
...options,
timestamp: options.timestamp || getCurrentTimestamp()
});
return generatedToken === token;
} else if (type === 'hotp') {
if (options.counter === undefined) {
throw new Error('Counter is required for HOTP');
}
const generatedToken = await generateHOTP(secret, options.counter, options);
return generatedToken === token;
} else {
throw new Error(`Unsupported OTP type: ${type}`);
}
}
/**
* 获取TOTP剩余秒数
* @param {Object} options - 配置选项
* @param {number} [options.period=30] - TOTP周期
* @param {number} [options.timestamp] - 指定时间戳
* @returns {number} 剩余秒数
*/
function getRemainingSeconds(options = {}) {
// 始终使用基础版本,确保一致性
// 确保传递正确的参数
const period = options.period || 30;
const timestamp = options.timestamp || getCurrentTimestamp();
const remaining = baseGetRemainingSeconds({
...options,
period,
timestamp
});
return remaining;
}
// 导出统一接口
module.exports = {
now: async (type, secret, counter) => {
if (type === 'totp') {
// 始终使用基础版本TOTP确保一致性
return await baseTOTP(secret, {
timestamp: getCurrentTimestamp(),
_forceRefresh: true
});
} else if (type === 'hotp') {
return await generateHOTP(secret, counter);
} else {
throw new Error(`Unsupported OTP type: ${type}`);
}
},
generate: generateOTP,
verify: verifyOTP,
getRemainingSeconds,
getCurrentTimestamp
};

127
utils/otp.test.js Normal file
View file

@ -0,0 +1,127 @@
/**
* OTP功能测试文件
* 可以在Node.js环境中运行此文件进行测试
*/
const otp = require('./otp.js');
/**
* 测试TOTP功能
*/
async function testTOTP() {
console.log('===== 测试TOTP功能 =====');
try {
// 生成随机密钥
const secret = otp.generateSecret();
console.log(`生成的密钥: ${secret}`);
// 生成TOTP
const token = await otp.generateTOTP(secret);
console.log(`当前TOTP: ${token}`);
// 验证TOTP
const isValid = await otp.verifyTOTP(token, secret);
console.log(`验证结果: ${isValid ? '通过' : '失败'}`);
// 获取剩余时间
const remaining = otp.getRemainingSeconds();
console.log(`剩余有效时间: ${remaining}`);
// 测试不同哈希算法
console.log('\n不同哈希算法测试:');
for (const algo of Object.keys(otp.HASH_ALGOS)) {
const algoToken = await otp.generateTOTP(secret, { algorithm: algo });
console.log(`${algo}: ${algoToken}`);
}
// 测试不同位数
console.log('\n不同位数测试:');
for (const digits of [6, 7, 8]) {
const digitToken = await otp.generateTOTP(secret, { digits });
console.log(`${digits}位: ${digitToken}`);
}
// 测试不同时间窗口
console.log('\n不同时间窗口测试:');
for (const period of [30, 60, 90]) {
const periodToken = await otp.generateTOTP(secret, { period });
console.log(`${period}秒: ${periodToken}`);
}
// 生成URI
const uri = otp.generateTOTPUri(secret, 'test@example.com', 'TestApp');
console.log(`\nTOTP URI: ${uri}`);
// 解析URI
const parsedUri = otp.parseOTPUri(uri);
console.log('解析URI结果:', parsedUri);
// 测试Steam令牌
const steamToken = await otp.generateSteamToken(secret);
console.log(`\nSteam令牌: ${steamToken}`);
console.log('\nTOTP测试完成');
} catch (error) {
console.error('TOTP测试失败:', error);
}
}
/**
* 测试HOTP功能
*/
async function testHOTP() {
console.log('\n===== 测试HOTP功能 =====');
try {
// 生成随机密钥
const secret = otp.generateSecret();
console.log(`生成的密钥: ${secret}`);
// 测试不同计数器值
console.log('\n不同计数器值测试:');
for (let counter = 0; counter < 5; counter++) {
const token = await otp.generateHOTP(secret, counter);
console.log(`计数器 ${counter}: ${token}`);
// 验证HOTP
const result = await otp.verifyHOTP(token, secret, counter);
console.log(`验证结果: ${result !== false ? '通过' : '失败'}`);
}
// 测试窗口验证
console.log('\n窗口验证测试:');
const token = await otp.generateHOTP(secret, 5);
for (let counter = 3; counter <= 7; counter++) {
const result = await otp.verifyHOTP(token, secret, counter, { window: 2 });
console.log(`计数器 ${counter} 验证结果: ${result !== false ? `通过,匹配计数器 ${result}` : '失败'}`);
}
// 生成URI
const uri = otp.generateHOTPUri(secret, 'test@example.com', 'TestApp', 0);
console.log(`\nHOTP URI: ${uri}`);
// 解析URI
const parsedUri = otp.parseOTPUri(uri);
console.log('解析URI结果:', parsedUri);
console.log('\nHOTP测试完成');
} catch (error) {
console.error('HOTP测试失败:', error);
}
}
/**
* 运行所有测试
*/
async function runTests() {
console.log('开始OTP功能测试...\n');
await testTOTP();
await testHOTP();
console.log('\n所有测试完成');
}
// 执行测试
runTests().catch(console.error);

71
utils/sjcl.min.js vendored Normal file
View file

@ -0,0 +1,71 @@
"use strict";var sjcl={cipher:{},hash:{},keyexchange:{},mode:{},misc:{},codec:{},exception:{corrupt:function(a){this.toString=function(){return"CORRUPT: "+this.message};this.message=a},invalid:function(a){this.toString=function(){return"INVALID: "+this.message};this.message=a},bug:function(a){this.toString=function(){return"BUG: "+this.message};this.message=a},notReady:function(a){this.toString=function(){return"NOT READY: "+this.message};this.message=a}}};
sjcl.cipher.aes=function(a){this.B[0][0][0]||this.H();var b,c,d,e,f=this.B[0][4],g=this.B[1];b=a.length;var h=1;if(4!==b&&6!==b&&8!==b)throw new sjcl.exception.invalid("invalid aes key size");this.b=[d=a.slice(0),e=[]];for(a=b;a<4*b+28;a++){c=d[a-1];if(0===a%b||8===b&&4===a%b)c=f[c>>>24]<<24^f[c>>16&255]<<16^f[c>>8&255]<<8^f[c&255],0===a%b&&(c=c<<8^c>>>24^h<<24,h=h<<1^283*(h>>7));d[a]=d[a-b]^c}for(b=0;a;b++,a--)c=d[b&3?a:a-4],e[b]=4>=a||4>b?c:g[0][f[c>>>24]]^g[1][f[c>>16&255]]^g[2][f[c>>8&255]]^g[3][f[c&
255]]};
sjcl.cipher.aes.prototype={encrypt:function(a){return aa(this,a,0)},decrypt:function(a){return aa(this,a,1)},B:[[[],[],[],[],[]],[[],[],[],[],[]]],H:function(){var a=this.B[0],b=this.B[1],c=a[4],d=b[4],e,f,g,h=[],k=[],l,m,n,p;for(e=0;0x100>e;e++)k[(h[e]=e<<1^283*(e>>7))^e]=e;for(f=g=0;!c[f];f^=l||1,g=k[g]||1)for(n=g^g<<1^g<<2^g<<3^g<<4,n=n>>8^n&255^99,c[f]=n,d[n]=f,m=h[e=h[l=h[f]]],p=0x1010101*m^0x10001*e^0x101*l^0x1010100*f,m=0x101*h[n]^0x1010100*n,e=0;4>e;e++)a[e][f]=m=m<<24^m>>>8,b[e][n]=p=p<<24^p>>>8;for(e=
0;5>e;e++)a[e]=a[e].slice(0),b[e]=b[e].slice(0)}};
function aa(a,b,c){if(4!==b.length)throw new sjcl.exception.invalid("invalid aes block size");var d=a.b[c],e=b[0]^d[0],f=b[c?3:1]^d[1],g=b[2]^d[2];b=b[c?1:3]^d[3];var h,k,l,m=d.length/4-2,n,p=4,t=[0,0,0,0];h=a.B[c];a=h[0];var r=h[1],C=h[2],B=h[3],D=h[4];for(n=0;n<m;n++)h=a[e>>>24]^r[f>>16&255]^C[g>>8&255]^B[b&255]^d[p],k=a[f>>>24]^r[g>>16&255]^C[b>>8&255]^B[e&255]^d[p+1],l=a[g>>>24]^r[b>>16&255]^C[e>>8&255]^B[f&255]^d[p+2],b=a[b>>>24]^r[e>>16&255]^C[f>>8&255]^B[g&255]^d[p+3],p+=4,e=h,f=k,g=l;for(n=
0;4>n;n++)t[c?3&-n:n]=D[e>>>24]<<24^D[f>>16&255]<<16^D[g>>8&255]<<8^D[b&255]^d[p++],h=e,e=f,f=g,g=b,b=h;return t}
sjcl.bitArray={bitSlice:function(a,b,c){a=sjcl.bitArray.aa(a.slice(b/32),32-(b&31)).slice(1);return void 0===c?a:sjcl.bitArray.clamp(a,c-b)},extract:function(a,b,c){var d=Math.floor(-b-c&31);return((b+c-1^b)&-32?a[b/32|0]<<32-d^a[b/32+1|0]>>>d:a[b/32|0]>>>d)&(1<<c)-1},concat:function(a,b){if(0===a.length||0===b.length)return a.concat(b);var c=a[a.length-1],d=sjcl.bitArray.getPartial(c);return 32===d?a.concat(b):sjcl.bitArray.aa(b,d,c|0,a.slice(0,a.length-1))},bitLength:function(a){var b=a.length;
return 0===b?0:32*(b-1)+sjcl.bitArray.getPartial(a[b-1])},clamp:function(a,b){if(32*a.length<b)return a;a=a.slice(0,Math.ceil(b/32));var c=a.length;b=b&31;0<c&&b&&(a[c-1]=sjcl.bitArray.partial(b,a[c-1]&2147483648>>b-1,1));return a},partial:function(a,b,c){return 32===a?b:(c?b|0:b<<32-a)+0x10000000000*a},getPartial:function(a){return Math.round(a/0x10000000000)||32},equal:function(a,b){if(sjcl.bitArray.bitLength(a)!==sjcl.bitArray.bitLength(b))return!1;var c=0,d;for(d=0;d<a.length;d++)c|=a[d]^b[d];
return 0===c},aa:function(a,b,c,d){var e;e=0;for(void 0===d&&(d=[]);32<=b;b-=32)d.push(c),c=0;if(0===b)return d.concat(a);for(e=0;e<a.length;e++)d.push(c|a[e]>>>b),c=a[e]<<32-b;e=a.length?a[a.length-1]:0;a=sjcl.bitArray.getPartial(e);d.push(sjcl.bitArray.partial(b+a&31,32<b+a?c:d.pop(),1));return d},s:function(a,b){return[a[0]^b[0],a[1]^b[1],a[2]^b[2],a[3]^b[3]]},byteswapM:function(a){var b,c;for(b=0;b<a.length;++b)c=a[b],a[b]=c>>>24|c>>>8&0xff00|(c&0xff00)<<8|c<<24;return a}};
sjcl.codec.utf8String={fromBits:function(a){var b="",c=sjcl.bitArray.bitLength(a),d,e;for(d=0;d<c/8;d++)0===(d&3)&&(e=a[d/4]),b+=String.fromCharCode(e>>>8>>>8>>>8),e<<=8;return decodeURIComponent(escape(b))},toBits:function(a){a=unescape(encodeURIComponent(a));var b=[],c,d=0;for(c=0;c<a.length;c++)d=d<<8|a.charCodeAt(c),3===(c&3)&&(b.push(d),d=0);c&3&&b.push(sjcl.bitArray.partial(8*(c&3),d));return b}};
sjcl.codec.hex={fromBits:function(a){var b="",c;for(c=0;c<a.length;c++)b+=((a[c]|0)+0xf00000000000).toString(16).substr(4);return b.substr(0,sjcl.bitArray.bitLength(a)/4)},toBits:function(a){var b,c=[],d;a=a.replace(/\s|0x/g,"");d=a.length;a=a+"00000000";for(b=0;b<a.length;b+=8)c.push(parseInt(a.substr(b,8),16)^0);return sjcl.bitArray.clamp(c,4*d)}};
sjcl.codec.base32={F:"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",Z:"0123456789ABCDEFGHIJKLMNOPQRSTUV",BITS:32,BASE:5,REMAINING:27,fromBits:function(a,b,c){var d=sjcl.codec.base32.BASE,e=sjcl.codec.base32.REMAINING,f="",g=0,h=sjcl.codec.base32.F,k=0,l=sjcl.bitArray.bitLength(a);c&&(h=sjcl.codec.base32.Z);for(c=0;f.length*d<l;)f+=h.charAt((k^a[c]>>>g)>>>e),g<d?(k=a[c]<<d-g,g+=e,c++):(k<<=d,g-=d);for(;f.length&7&&!b;)f+="=";return f},toBits:function(a,b){a=a.replace(/\s|=/g,"").toUpperCase();var c=sjcl.codec.base32.BITS,
d=sjcl.codec.base32.BASE,e=sjcl.codec.base32.REMAINING,f=[],g,h=0,k=sjcl.codec.base32.F,l=0,m,n="base32";b&&(k=sjcl.codec.base32.Z,n="base32hex");for(g=0;g<a.length;g++){m=k.indexOf(a.charAt(g));if(0>m){if(!b)try{return sjcl.codec.base32hex.toBits(a)}catch(p){}throw new sjcl.exception.invalid("this isn't "+n+"!");}h>e?(h-=e,f.push(l^m>>>h),l=m<<c-h):(h+=d,l^=m<<c-h)}h&56&&f.push(sjcl.bitArray.partial(h&56,l,1));return f}};
sjcl.codec.base32hex={fromBits:function(a,b){return sjcl.codec.base32.fromBits(a,b,1)},toBits:function(a){return sjcl.codec.base32.toBits(a,1)}};
sjcl.codec.base64={F:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",fromBits:function(a,b,c){var d="",e=0,f=sjcl.codec.base64.F,g=0,h=sjcl.bitArray.bitLength(a);c&&(f=f.substr(0,62)+"-_");for(c=0;6*d.length<h;)d+=f.charAt((g^a[c]>>>e)>>>26),6>e?(g=a[c]<<6-e,e+=26,c++):(g<<=6,e-=6);for(;d.length&3&&!b;)d+="=";return d},toBits:function(a,b){a=a.replace(/\s|=/g,"");var c=[],d,e=0,f=sjcl.codec.base64.F,g=0,h;b&&(f=f.substr(0,62)+"-_");for(d=0;d<a.length;d++){h=f.indexOf(a.charAt(d));
if(0>h)throw new sjcl.exception.invalid("this isn't base64!");26<e?(e-=26,c.push(g^h>>>e),g=h<<32-e):(e+=6,g^=h<<32-e)}e&56&&c.push(sjcl.bitArray.partial(e&56,g,1));return c}};sjcl.codec.base64url={fromBits:function(a){return sjcl.codec.base64.fromBits(a,1,1)},toBits:function(a){return sjcl.codec.base64.toBits(a,1)}};
sjcl.codec.bytes={fromBits:function(a){var b=[],c=sjcl.bitArray.bitLength(a),d,e;for(d=0;d<c/8;d++)0===(d&3)&&(e=a[d/4]),b.push(e>>>24),e<<=8;return b},toBits:function(a){var b=[],c,d=0;for(c=0;c<a.length;c++)d=d<<8|a[c],3===(c&3)&&(b.push(d),d=0);c&3&&b.push(sjcl.bitArray.partial(8*(c&3),d));return b}};sjcl.hash.sha256=function(a){this.b[0]||this.H();a?(this.g=a.g.slice(0),this.c=a.c.slice(0),this.a=a.a):this.reset()};sjcl.hash.sha256.hash=function(a){return(new sjcl.hash.sha256).update(a).finalize()};
sjcl.hash.sha256.prototype={blockSize:512,reset:function(){this.g=this.j.slice(0);this.c=[];this.a=0;return this},update:function(a){"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));var b,c=this.c=sjcl.bitArray.concat(this.c,a);b=this.a;a=this.a=b+sjcl.bitArray.bitLength(a);if(0x1fffffffffffff<a)throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits");if("undefined"!==typeof Uint32Array){var d=new Uint32Array(c),e=0;for(b=512+b-(512+b&0x1ff);b<=a;b+=512)this.h(d.subarray(16*e,
16*(e+1))),e+=1;c.splice(0,16*e)}else for(b=512+b-(512+b&0x1ff);b<=a;b+=512)this.h(c.splice(0,16));return this},finalize:function(){var a,b=this.c,c=this.g,b=sjcl.bitArray.concat(b,[sjcl.bitArray.partial(1,1)]);for(a=b.length+2;a&15;a++)b.push(0);b.push(Math.floor(this.a/0x100000000));for(b.push(this.a|0);b.length;)this.h(b.splice(0,16));this.reset();return c},j:[],b:[],H:function(){function a(a){return 0x100000000*(a-Math.floor(a))|0}for(var b=0,c=2,d,e;64>b;c++){e=!0;for(d=2;d*d<=c;d++)if(0===c%d){e=
!1;break}e&&(8>b&&(this.j[b]=a(Math.pow(c,.5))),this.b[b]=a(Math.pow(c,1/3)),b++)}},h:function(a){var b,c,d,e=this.g,f=this.b,g=e[0],h=e[1],k=e[2],l=e[3],m=e[4],n=e[5],p=e[6],t=e[7];for(b=0;64>b;b++)16>b?c=a[b]:(c=a[b+1&15],d=a[b+14&15],c=a[b&15]=(c>>>7^c>>>18^c>>>3^c<<25^c<<14)+(d>>>17^d>>>19^d>>>10^d<<15^d<<13)+a[b&15]+a[b+9&15]|0),c=c+t+(m>>>6^m>>>11^m>>>25^m<<26^m<<21^m<<7)+(p^m&(n^p))+f[b],t=p,p=n,n=m,m=l+c|0,l=k,k=h,h=g,g=c+(h&k^l&(h^k))+(h>>>2^h>>>13^h>>>22^h<<30^h<<19^h<<10)|0;e[0]=e[0]+g|
0;e[1]=e[1]+h|0;e[2]=e[2]+k|0;e[3]=e[3]+l|0;e[4]=e[4]+m|0;e[5]=e[5]+n|0;e[6]=e[6]+p|0;e[7]=e[7]+t|0}};sjcl.hash.sha512=function(a){this.b[0]||this.H();a?(this.g=a.g.slice(0),this.c=a.c.slice(0),this.a=a.a):this.reset()};sjcl.hash.sha512.hash=function(a){return(new sjcl.hash.sha512).update(a).finalize()};
sjcl.hash.sha512.prototype={blockSize:1024,reset:function(){this.g=this.j.slice(0);this.c=[];this.a=0;return this},update:function(a){"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));var b,c=this.c=sjcl.bitArray.concat(this.c,a);b=this.a;a=this.a=b+sjcl.bitArray.bitLength(a);if(0x1fffffffffffff<a)throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits");if("undefined"!==typeof Uint32Array){var d=new Uint32Array(c),e=0;for(b=1024+b-(1024+b&1023);b<=a;b+=1024)this.h(d.subarray(32*
e,32*(e+1))),e+=1;c.splice(0,32*e)}else for(b=1024+b-(1024+b&1023);b<=a;b+=1024)this.h(c.splice(0,32));return this},finalize:function(){var a,b=this.c,c=this.g,b=sjcl.bitArray.concat(b,[sjcl.bitArray.partial(1,1)]);for(a=b.length+4;a&31;a++)b.push(0);b.push(0);b.push(0);b.push(Math.floor(this.a/0x100000000));for(b.push(this.a|0);b.length;)this.h(b.splice(0,32));this.reset();return c},j:[],ma:[12372232,13281083,9762859,1914609,15106769,4090911,4308331,8266105],b:[],oa:[2666018,15689165,5061423,9034684,
4764984,380953,1658779,7176472,197186,7368638,14987916,16757986,8096111,1480369,13046325,6891156,15813330,5187043,9229749,11312229,2818677,10937475,4324308,1135541,6741931,11809296,16458047,15666916,11046850,698149,229999,945776,13774844,2541862,12856045,9810911,11494366,7844520,15576806,8533307,15795044,4337665,16291729,5553712,15684120,6662416,7413802,12308920,13816008,4303699,9366425,10176680,13195875,4295371,6546291,11712675,15708924,1519456,15772530,6568428,6495784,8568297,13007125,7492395,2515356,
12632583,14740254,7262584,1535930,13146278,16321966,1853211,294276,13051027,13221564,1051980,4080310,6651434,14088940,4675607],H:function(){function a(a){return 0x100000000*(a-Math.floor(a))|0}function b(a){return 0x10000000000*(a-Math.floor(a))&255}for(var c=0,d=2,e,f;80>c;d++){f=!0;for(e=2;e*e<=d;e++)if(0===d%e){f=!1;break}f&&(8>c&&(this.j[2*c]=a(Math.pow(d,.5)),this.j[2*c+1]=b(Math.pow(d,.5))<<24|this.ma[c]),this.b[2*c]=a(Math.pow(d,1/3)),this.b[2*c+1]=b(Math.pow(d,1/3))<<24|this.oa[c],c++)}},h:function(a){var b,
c,d=this.g,e=this.b,f=d[0],g=d[1],h=d[2],k=d[3],l=d[4],m=d[5],n=d[6],p=d[7],t=d[8],r=d[9],C=d[10],B=d[11],D=d[12],P=d[13],ea=d[14],Q=d[15],v;if("undefined"!==typeof Uint32Array){v=Array(160);for(var u=0;32>u;u++)v[u]=a[u]}else v=a;var u=f,w=g,G=h,E=k,H=l,F=m,V=n,I=p,y=t,x=r,R=C,J=B,S=D,K=P,W=ea,L=Q;for(a=0;80>a;a++){if(16>a)b=v[2*a],c=v[2*a+1];else{c=v[2*(a-15)];var q=v[2*(a-15)+1];b=(q<<31|c>>>1)^(q<<24|c>>>8)^c>>>7;var z=(c<<31|q>>>1)^(c<<24|q>>>8)^(c<<25|q>>>7);c=v[2*(a-2)];var A=v[2*(a-2)+1],
q=(A<<13|c>>>19)^(c<<3|A>>>29)^c>>>6,A=(c<<13|A>>>19)^(A<<3|c>>>29)^(c<<26|A>>>6),X=v[2*(a-7)],Y=v[2*(a-16)],M=v[2*(a-16)+1];c=z+v[2*(a-7)+1];b=b+X+(c>>>0<z>>>0?1:0);c+=A;b+=q+(c>>>0<A>>>0?1:0);c+=M;b+=Y+(c>>>0<M>>>0?1:0)}v[2*a]=b|=0;v[2*a+1]=c|=0;var X=y&R^~y&S,fa=x&J^~x&K,A=u&G^u&H^G&H,ja=w&E^w&F^E&F,Y=(w<<4|u>>>28)^(u<<30|w>>>2)^(u<<25|w>>>7),M=(u<<4|w>>>28)^(w<<30|u>>>2)^(w<<25|u>>>7),ka=e[2*a],ga=e[2*a+1],q=L+((y<<18|x>>>14)^(y<<14|x>>>18)^(x<<23|y>>>9)),z=W+((x<<18|y>>>14)^(x<<14|y>>>18)^(y<<
23|x>>>9))+(q>>>0<L>>>0?1:0),q=q+fa,z=z+(X+(q>>>0<fa>>>0?1:0)),q=q+ga,z=z+(ka+(q>>>0<ga>>>0?1:0)),q=q+c|0,z=z+(b+(q>>>0<c>>>0?1:0));c=M+ja;b=Y+A+(c>>>0<M>>>0?1:0);W=S;L=K;S=R;K=J;R=y;J=x;x=I+q|0;y=V+z+(x>>>0<I>>>0?1:0)|0;V=H;I=F;H=G;F=E;G=u;E=w;w=q+c|0;u=z+b+(w>>>0<q>>>0?1:0)|0}g=d[1]=g+w|0;d[0]=f+u+(g>>>0<w>>>0?1:0)|0;k=d[3]=k+E|0;d[2]=h+G+(k>>>0<E>>>0?1:0)|0;m=d[5]=m+F|0;d[4]=l+H+(m>>>0<F>>>0?1:0)|0;p=d[7]=p+I|0;d[6]=n+V+(p>>>0<I>>>0?1:0)|0;r=d[9]=r+x|0;d[8]=t+y+(r>>>0<x>>>0?1:0)|0;B=d[11]=B+J|
0;d[10]=C+R+(B>>>0<J>>>0?1:0)|0;P=d[13]=P+K|0;d[12]=D+S+(P>>>0<K>>>0?1:0)|0;Q=d[15]=Q+L|0;d[14]=ea+W+(Q>>>0<L>>>0?1:0)|0}};sjcl.hash.sha1=function(a){a?(this.g=a.g.slice(0),this.c=a.c.slice(0),this.a=a.a):this.reset()};sjcl.hash.sha1.hash=function(a){return(new sjcl.hash.sha1).update(a).finalize()};
sjcl.hash.sha1.prototype={blockSize:512,reset:function(){this.g=this.j.slice(0);this.c=[];this.a=0;return this},update:function(a){"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));var b,c=this.c=sjcl.bitArray.concat(this.c,a);b=this.a;a=this.a=b+sjcl.bitArray.bitLength(a);if(0x1fffffffffffff<a)throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits");if("undefined"!==typeof Uint32Array){var d=new Uint32Array(c),e=0;for(b=this.blockSize+b-(this.blockSize+b&this.blockSize-1);b<=
a;b+=this.blockSize)this.h(d.subarray(16*e,16*(e+1))),e+=1;c.splice(0,16*e)}else for(b=this.blockSize+b-(this.blockSize+b&this.blockSize-1);b<=a;b+=this.blockSize)this.h(c.splice(0,16));return this},finalize:function(){var a,b=this.c,c=this.g,b=sjcl.bitArray.concat(b,[sjcl.bitArray.partial(1,1)]);for(a=b.length+2;a&15;a++)b.push(0);b.push(Math.floor(this.a/0x100000000));for(b.push(this.a|0);b.length;)this.h(b.splice(0,16));this.reset();return c},j:[1732584193,4023233417,2562383102,271733878,3285377520],
b:[1518500249,1859775393,2400959708,3395469782],h:function(a){var b,c,d,e,f,g,h=this.g,k;if("undefined"!==typeof Uint32Array)for(k=Array(80),c=0;16>c;c++)k[c]=a[c];else k=a;c=h[0];d=h[1];e=h[2];f=h[3];g=h[4];for(a=0;79>=a;a++)16<=a&&(b=k[a-3]^k[a-8]^k[a-14]^k[a-16],k[a]=b<<1|b>>>31),b=19>=a?d&e|~d&f:39>=a?d^e^f:59>=a?d&e|d&f|e&f:79>=a?d^e^f:void 0,b=(c<<5|c>>>27)+b+g+k[a]+this.b[Math.floor(a/20)]|0,g=f,f=e,e=d<<30|d>>>2,d=c,c=b;h[0]=h[0]+c|0;h[1]=h[1]+d|0;h[2]=h[2]+e|0;h[3]=h[3]+f|0;h[4]=h[4]+g|0}};
sjcl.mode.ccm={name:"ccm",J:[],listenProgress:function(a){sjcl.mode.ccm.J.push(a)},unListenProgress:function(a){a=sjcl.mode.ccm.J.indexOf(a);-1<a&&sjcl.mode.ccm.J.splice(a,1)},ga:function(a){var b=sjcl.mode.ccm.J.slice(),c;for(c=0;c<b.length;c+=1)b[c](a)},encrypt:function(a,b,c,d,e){var f,g=b.slice(0),h=sjcl.bitArray,k=h.bitLength(c)/8,l=h.bitLength(g)/8;e=e||64;d=d||[];if(7>k)throw new sjcl.exception.invalid("ccm: iv must be at least 7 bytes");for(f=2;4>f&&l>>>8*f;f++);f<15-k&&(f=15-k);c=h.clamp(c,
8*(15-f));b=sjcl.mode.ccm.X(a,b,c,d,e,f);g=sjcl.mode.ccm.G(a,g,c,b,e,f);return h.concat(g.data,g.tag)},decrypt:function(a,b,c,d,e){e=e||64;d=d||[];var f=sjcl.bitArray,g=f.bitLength(c)/8,h=f.bitLength(b),k=f.clamp(b,h-e),l=f.bitSlice(b,h-e),h=(h-e)/8;if(7>g)throw new sjcl.exception.invalid("ccm: iv must be at least 7 bytes");for(b=2;4>b&&h>>>8*b;b++);b<15-g&&(b=15-g);c=f.clamp(c,8*(15-b));k=sjcl.mode.ccm.G(a,k,c,l,e,b);a=sjcl.mode.ccm.X(a,k.data,c,d,e,b);if(!f.equal(k.tag,a))throw new sjcl.exception.corrupt("ccm: tag doesn't match");
return k.data},qa:function(a,b,c,d,e,f){var g=[],h=sjcl.bitArray,k=h.s;d=[h.partial(8,(b.length?64:0)|d-2<<2|f-1)];d=h.concat(d,c);d[3]|=e;d=a.encrypt(d);if(b.length)for(c=h.bitLength(b)/8,65279>=c?g=[h.partial(16,c)]:0xffffffff>=c&&(g=h.concat([h.partial(16,65534)],[c])),g=h.concat(g,b),b=0;b<g.length;b+=4)d=a.encrypt(k(d,g.slice(b,b+4).concat([0,0,0])));return d},X:function(a,b,c,d,e,f){var g=sjcl.bitArray,h=g.s;e/=8;if(e%2||4>e||16<e)throw new sjcl.exception.invalid("ccm: invalid tag length");
if(0xffffffff<d.length||0xffffffff<b.length)throw new sjcl.exception.bug("ccm: can't deal with 4GiB or more data");c=sjcl.mode.ccm.qa(a,d,c,e,g.bitLength(b)/8,f);for(d=0;d<b.length;d+=4)c=a.encrypt(h(c,b.slice(d,d+4).concat([0,0,0])));return g.clamp(c,8*e)},G:function(a,b,c,d,e,f){var g,h=sjcl.bitArray;g=h.s;var k=b.length,l=h.bitLength(b),m=k/50,n=m;c=h.concat([h.partial(8,f-1)],c).concat([0,0,0]).slice(0,4);d=h.bitSlice(g(d,a.encrypt(c)),0,e);if(!k)return{tag:d,data:[]};for(g=0;g<k;g+=4)g>m&&(sjcl.mode.ccm.ga(g/
k),m+=n),c[3]++,e=a.encrypt(c),b[g]^=e[0],b[g+1]^=e[1],b[g+2]^=e[2],b[g+3]^=e[3];return{tag:d,data:h.clamp(b,l)}}};
sjcl.mode.ocb2={name:"ocb2",encrypt:function(a,b,c,d,e,f){if(128!==sjcl.bitArray.bitLength(c))throw new sjcl.exception.invalid("ocb iv must be 128 bits");var g,h=sjcl.mode.ocb2.U,k=sjcl.bitArray,l=k.s,m=[0,0,0,0];c=h(a.encrypt(c));var n,p=[];d=d||[];e=e||64;for(g=0;g+4<b.length;g+=4)n=b.slice(g,g+4),m=l(m,n),p=p.concat(l(c,a.encrypt(l(c,n)))),c=h(c);n=b.slice(g);b=k.bitLength(n);g=a.encrypt(l(c,[0,0,0,b]));n=k.clamp(l(n.concat([0,0,0]),g),b);m=l(m,l(n.concat([0,0,0]),g));m=a.encrypt(l(m,l(c,h(c))));
d.length&&(m=l(m,f?d:sjcl.mode.ocb2.pmac(a,d)));return p.concat(k.concat(n,k.clamp(m,e)))},decrypt:function(a,b,c,d,e,f){if(128!==sjcl.bitArray.bitLength(c))throw new sjcl.exception.invalid("ocb iv must be 128 bits");e=e||64;var g=sjcl.mode.ocb2.U,h=sjcl.bitArray,k=h.s,l=[0,0,0,0],m=g(a.encrypt(c)),n,p,t=sjcl.bitArray.bitLength(b)-e,r=[];d=d||[];for(c=0;c+4<t/32;c+=4)n=k(m,a.decrypt(k(m,b.slice(c,c+4)))),l=k(l,n),r=r.concat(n),m=g(m);p=t-32*c;n=a.encrypt(k(m,[0,0,0,p]));n=k(n,h.clamp(b.slice(c),p).concat([0,
0,0]));l=k(l,n);l=a.encrypt(k(l,k(m,g(m))));d.length&&(l=k(l,f?d:sjcl.mode.ocb2.pmac(a,d)));if(!h.equal(h.clamp(l,e),h.bitSlice(b,t)))throw new sjcl.exception.corrupt("ocb: tag doesn't match");return r.concat(h.clamp(n,p))},pmac:function(a,b){var c,d=sjcl.mode.ocb2.U,e=sjcl.bitArray,f=e.s,g=[0,0,0,0],h=a.encrypt([0,0,0,0]),h=f(h,d(d(h)));for(c=0;c+4<b.length;c+=4)h=d(h),g=f(g,a.encrypt(f(h,b.slice(c,c+4))));c=b.slice(c);128>e.bitLength(c)&&(h=f(h,d(h)),c=e.concat(c,[-2147483648,0,0,0]));g=f(g,c);
return a.encrypt(f(d(f(h,d(h))),g))},U:function(a){return[a[0]<<1^a[1]>>>31,a[1]<<1^a[2]>>>31,a[2]<<1^a[3]>>>31,a[3]<<1^135*(a[0]>>>31)]}};
sjcl.mode.gcm={name:"gcm",encrypt:function(a,b,c,d,e){var f=b.slice(0);b=sjcl.bitArray;d=d||[];a=sjcl.mode.gcm.G(!0,a,f,d,c,e||128);return b.concat(a.data,a.tag)},decrypt:function(a,b,c,d,e){var f=b.slice(0),g=sjcl.bitArray,h=g.bitLength(f);e=e||128;d=d||[];e<=h?(b=g.bitSlice(f,h-e),f=g.bitSlice(f,0,h-e)):(b=f,f=[]);a=sjcl.mode.gcm.G(!1,a,f,d,c,e);if(!g.equal(a.tag,b))throw new sjcl.exception.corrupt("gcm: tag doesn't match");return a.data},la:function(a,b){var c,d,e,f,g,h=sjcl.bitArray.s;e=[0,0,
0,0];f=b.slice(0);for(c=0;128>c;c++){(d=0!==(a[Math.floor(c/32)]&1<<31-c%32))&&(e=h(e,f));g=0!==(f[3]&1);for(d=3;0<d;d--)f[d]=f[d]>>>1|(f[d-1]&1)<<31;f[0]>>>=1;g&&(f[0]^=-0x1f000000)}return e},u:function(a,b,c){var d,e=c.length;b=b.slice(0);for(d=0;d<e;d+=4)b[0]^=0xffffffff&c[d],b[1]^=0xffffffff&c[d+1],b[2]^=0xffffffff&c[d+2],b[3]^=0xffffffff&c[d+3],b=sjcl.mode.gcm.la(b,a);return b},G:function(a,b,c,d,e,f){var g,h,k,l,m,n,p,t,r=sjcl.bitArray;n=c.length;p=r.bitLength(c);t=r.bitLength(d);h=r.bitLength(e);
g=b.encrypt([0,0,0,0]);96===h?(e=e.slice(0),e=r.concat(e,[1])):(e=sjcl.mode.gcm.u(g,[0,0,0,0],e),e=sjcl.mode.gcm.u(g,e,[0,0,Math.floor(h/0x100000000),h&0xffffffff]));h=sjcl.mode.gcm.u(g,[0,0,0,0],d);m=e.slice(0);d=h.slice(0);a||(d=sjcl.mode.gcm.u(g,h,c));for(l=0;l<n;l+=4)m[3]++,k=b.encrypt(m),c[l]^=k[0],c[l+1]^=k[1],c[l+2]^=k[2],c[l+3]^=k[3];c=r.clamp(c,p);a&&(d=sjcl.mode.gcm.u(g,h,c));a=[Math.floor(t/0x100000000),t&0xffffffff,Math.floor(p/0x100000000),p&0xffffffff];d=sjcl.mode.gcm.u(g,d,a);k=b.encrypt(e);
d[0]^=k[0];d[1]^=k[1];d[2]^=k[2];d[3]^=k[3];return{tag:r.bitSlice(d,0,f),data:c}}};sjcl.misc.hmac=function(a,b){this.Y=b=b||sjcl.hash.sha256;var c=[[],[]],d,e=b.prototype.blockSize/32;this.D=[new b,new b];a.length>e&&(a=b.hash(a));for(d=0;d<e;d++)c[0][d]=a[d]^909522486,c[1][d]=a[d]^1549556828;this.D[0].update(c[0]);this.D[1].update(c[1]);this.T=new b(this.D[0])};
sjcl.misc.hmac.prototype.encrypt=sjcl.misc.hmac.prototype.mac=function(a){if(this.ba)throw new sjcl.exception.invalid("encrypt on already updated hmac called!");this.update(a);return this.digest(a)};sjcl.misc.hmac.prototype.reset=function(){this.T=new this.Y(this.D[0]);this.ba=!1};sjcl.misc.hmac.prototype.update=function(a){this.ba=!0;this.T.update(a)};sjcl.misc.hmac.prototype.digest=function(){var a=this.T.finalize(),a=(new this.Y(this.D[1])).update(a).finalize();this.reset();return a};
sjcl.misc.pbkdf2=function(a,b,c,d,e){c=c||1E4;if(0>d||0>c)throw new sjcl.exception.invalid("invalid params to pbkdf2");"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));"string"===typeof b&&(b=sjcl.codec.utf8String.toBits(b));e=e||sjcl.misc.hmac;a=new e(a);var f,g,h,k,l=[],m=sjcl.bitArray;for(k=1;32*l.length<(d||1);k++){e=f=a.encrypt(m.concat(b,[k]));for(g=1;g<c;g++)for(f=a.encrypt(f),h=0;h<f.length;h++)e[h]^=f[h];l=l.concat(e)}d&&(l=m.clamp(l,d));return l};
sjcl.prng=function(a){this.i=[new sjcl.hash.sha256];this.w=[0];this.S=0;this.K={};this.R=0;this.W={};this.$=this.l=this.A=this.ia=0;this.b=[0,0,0,0,0,0,0,0];this.o=[0,0,0,0];this.O=void 0;this.P=a;this.I=!1;this.N={progress:{},seeded:{}};this.C=this.ha=0;this.L=1;this.M=2;this.da=0x10000;this.V=[0,48,64,96,128,192,0x100,384,512,768,1024];this.ea=3E4;this.ca=80};
sjcl.prng.prototype={randomWords:function(a,b){var c=[],d;d=this.isReady(b);var e;if(d===this.C)throw new sjcl.exception.notReady("generator isn't seeded");if(d&this.M){d=!(d&this.L);e=[];var f=0,g;this.$=e[0]=(new Date).valueOf()+this.ea;for(g=0;16>g;g++)e.push(0x100000000*Math.random()|0);for(g=0;g<this.i.length&&(e=e.concat(this.i[g].finalize()),f+=this.w[g],this.w[g]=0,d||!(this.S&1<<g));g++);this.S>=1<<this.i.length&&(this.i.push(new sjcl.hash.sha256),this.w.push(0));this.l-=f;f>this.A&&(this.A=
f);this.S++;this.b=sjcl.hash.sha256.hash(this.b.concat(e));this.O=new sjcl.cipher.aes(this.b);for(d=0;4>d&&(this.o[d]=this.o[d]+1|0,!this.o[d]);d++);}for(d=0;d<a;d+=4)0===(d+1)%this.da&&ba(this),e=N(this),c.push(e[0],e[1],e[2],e[3]);ba(this);return c.slice(0,a)},setDefaultParanoia:function(a,b){if(0===a&&"Setting paranoia=0 will ruin your security; use it only for testing"!==b)throw new sjcl.exception.invalid("Setting paranoia=0 will ruin your security; use it only for testing");this.P=a},addEntropy:function(a,
b,c){c=c||"user";var d,e,f=(new Date).valueOf(),g=this.K[c],h=this.isReady(),k=0;d=this.W[c];void 0===d&&(d=this.W[c]=this.ia++);void 0===g&&(g=this.K[c]=0);this.K[c]=(this.K[c]+1)%this.i.length;switch(typeof a){case "number":void 0===b&&(b=1);this.i[g].update([d,this.R++,1,b,f,1,a|0]);break;case "object":c=Object.prototype.toString.call(a);if("[object Uint32Array]"===c){e=[];for(c=0;c<a.length;c++)e.push(a[c]);a=e}else for("[object Array]"!==c&&(k=1),c=0;c<a.length&&!k;c++)"number"!==typeof a[c]&&
(k=1);if(!k){if(void 0===b)for(c=b=0;c<a.length;c++)for(e=a[c];0<e;)b++,e=e>>>1;this.i[g].update([d,this.R++,2,b,f,a.length].concat(a))}break;case "string":void 0===b&&(b=a.length);this.i[g].update([d,this.R++,3,b,f,a.length]);this.i[g].update(a);break;default:k=1}if(k)throw new sjcl.exception.bug("random: addEntropy only supports number, array of numbers or string");this.w[g]+=b;this.l+=b;h===this.C&&(this.isReady()!==this.C&&ca("seeded",Math.max(this.A,this.l)),ca("progress",this.getProgress()))},
isReady:function(a){a=this.V[void 0!==a?a:this.P];return this.A&&this.A>=a?this.w[0]>this.ca&&(new Date).valueOf()>this.$?this.M|this.L:this.L:this.l>=a?this.M|this.C:this.C},getProgress:function(a){a=this.V[a?a:this.P];return this.A>=a?1:this.l>a?1:this.l/a},startCollectors:function(){if(!this.I){this.f={loadTimeCollector:O(this,this.pa),mouseCollector:O(this,this.ra),keyboardCollector:O(this,this.na),accelerometerCollector:O(this,this.fa),touchCollector:O(this,this.ta)};if(window.addEventListener)window.addEventListener("load",
this.f.loadTimeCollector,!1),window.addEventListener("mousemove",this.f.mouseCollector,!1),window.addEventListener("keypress",this.f.keyboardCollector,!1),window.addEventListener("devicemotion",this.f.accelerometerCollector,!1),window.addEventListener("touchmove",this.f.touchCollector,!1);else if(document.attachEvent)document.attachEvent("onload",this.f.loadTimeCollector),document.attachEvent("onmousemove",this.f.mouseCollector),document.attachEvent("keypress",this.f.keyboardCollector);else throw new sjcl.exception.bug("can't attach event");
this.I=!0}},stopCollectors:function(){this.I&&(window.removeEventListener?(window.removeEventListener("load",this.f.loadTimeCollector,!1),window.removeEventListener("mousemove",this.f.mouseCollector,!1),window.removeEventListener("keypress",this.f.keyboardCollector,!1),window.removeEventListener("devicemotion",this.f.accelerometerCollector,!1),window.removeEventListener("touchmove",this.f.touchCollector,!1)):document.detachEvent&&(document.detachEvent("onload",this.f.loadTimeCollector),document.detachEvent("onmousemove",
this.f.mouseCollector),document.detachEvent("keypress",this.f.keyboardCollector)),this.I=!1)},addEventListener:function(a,b){this.N[a][this.ha++]=b},removeEventListener:function(a,b){var c,d,e=this.N[a],f=[];for(d in e)e.hasOwnProperty(d)&&e[d]===b&&f.push(d);for(c=0;c<f.length;c++)d=f[c],delete e[d]},na:function(){T(this,1)},ra:function(a){var b,c;try{b=a.x||a.clientX||a.offsetX||0,c=a.y||a.clientY||a.offsetY||0}catch(d){c=b=0}0!=b&&0!=c&&this.addEntropy([b,c],2,"mouse");T(this,0)},ta:function(a){a=
a.touches[0]||a.changedTouches[0];this.addEntropy([a.pageX||a.clientX,a.pageY||a.clientY],1,"touch");T(this,0)},pa:function(){T(this,2)},fa:function(a){a=a.accelerationIncludingGravity.x||a.accelerationIncludingGravity.y||a.accelerationIncludingGravity.z;if(window.orientation){var b=window.orientation;"number"===typeof b&&this.addEntropy(b,1,"accelerometer")}a&&this.addEntropy(a,2,"accelerometer");T(this,0)}};
function ca(a,b){var c,d=sjcl.random.N[a],e=[];for(c in d)d.hasOwnProperty(c)&&e.push(d[c]);for(c=0;c<e.length;c++)e[c](b)}function T(a,b){"undefined"!==typeof window&&window.performance&&"function"===typeof window.performance.now?a.addEntropy(window.performance.now(),b,"loadtime"):a.addEntropy((new Date).valueOf(),b,"loadtime")}function ba(a){a.b=N(a).concat(N(a));a.O=new sjcl.cipher.aes(a.b)}function N(a){for(var b=0;4>b&&(a.o[b]=a.o[b]+1|0,!a.o[b]);b++);return a.O.encrypt(a.o)}
function O(a,b){return function(){b.apply(a,arguments)}}sjcl.random=new sjcl.prng(6);
a:try{var U,da,Z,ha;if(ha="undefined"!==typeof module&&module.exports){var ia;try{ia=require("crypto")}catch(a){ia=null}ha=da=ia}if(ha&&da.randomBytes)U=da.randomBytes(128),U=new Uint32Array((new Uint8Array(U)).buffer),sjcl.random.addEntropy(U,1024,"crypto['randomBytes']");else if("undefined"!==typeof window&&"undefined"!==typeof Uint32Array){Z=new Uint32Array(32);if(window.crypto&&window.crypto.getRandomValues)window.crypto.getRandomValues(Z);else if(window.msCrypto&&window.msCrypto.getRandomValues)window.msCrypto.getRandomValues(Z);
else break a;sjcl.random.addEntropy(Z,1024,"crypto['getRandomValues']")}}catch(a){"undefined"!==typeof window&&window.console&&(console.log("There was an error collecting entropy from the browser:"),console.log(a))}
sjcl.json={defaults:{v:1,iter:1E4,ks:128,ts:64,mode:"ccm",adata:"",cipher:"aes"},ka:function(a,b,c,d){c=c||{};d=d||{};var e=sjcl.json,f=e.m({iv:sjcl.random.randomWords(4,0)},e.defaults),g;e.m(f,c);c=f.adata;"string"===typeof f.salt&&(f.salt=sjcl.codec.base64.toBits(f.salt));"string"===typeof f.iv&&(f.iv=sjcl.codec.base64.toBits(f.iv));if(!sjcl.mode[f.mode]||!sjcl.cipher[f.cipher]||"string"===typeof a&&100>=f.iter||64!==f.ts&&96!==f.ts&&128!==f.ts||128!==f.ks&&192!==f.ks&&0x100!==f.ks||2>f.iv.length||
4<f.iv.length)throw new sjcl.exception.invalid("json encrypt: invalid parameters");"string"===typeof a?(g=sjcl.misc.cachedPbkdf2(a,f),a=g.key.slice(0,f.ks/32),f.salt=g.salt):sjcl.ecc&&a instanceof sjcl.ecc.elGamal.publicKey&&(g=a.kem(),f.kemtag=g.tag,a=g.key.slice(0,f.ks/32));"string"===typeof b&&(b=sjcl.codec.utf8String.toBits(b));"string"===typeof c&&(f.adata=c=sjcl.codec.utf8String.toBits(c));g=new sjcl.cipher[f.cipher](a);e.m(d,f);d.key=a;f.ct="ccm"===f.mode&&sjcl.arrayBuffer&&sjcl.arrayBuffer.ccm&&
b instanceof ArrayBuffer?sjcl.arrayBuffer.ccm.encrypt(g,b,f.iv,c,f.ts):sjcl.mode[f.mode].encrypt(g,b,f.iv,c,f.ts);return f},encrypt:function(a,b,c,d){var e=sjcl.json,f=e.ka.apply(e,arguments);return e.encode(f)},ja:function(a,b,c,d){c=c||{};d=d||{};var e=sjcl.json;b=e.m(e.m(e.m({},e.defaults),b),c,!0);var f,g;f=b.adata;"string"===typeof b.salt&&(b.salt=sjcl.codec.base64.toBits(b.salt));"string"===typeof b.iv&&(b.iv=sjcl.codec.base64.toBits(b.iv));if(!sjcl.mode[b.mode]||!sjcl.cipher[b.cipher]||"string"===
typeof a&&100>=b.iter||64!==b.ts&&96!==b.ts&&128!==b.ts||128!==b.ks&&192!==b.ks&&0x100!==b.ks||!b.iv||2>b.iv.length||4<b.iv.length)throw new sjcl.exception.invalid("json decrypt: invalid parameters");"string"===typeof a?(g=sjcl.misc.cachedPbkdf2(a,b),a=g.key.slice(0,b.ks/32),b.salt=g.salt):sjcl.ecc&&a instanceof sjcl.ecc.elGamal.secretKey&&(a=a.unkem(sjcl.codec.base64.toBits(b.kemtag)).slice(0,b.ks/32));"string"===typeof f&&(f=sjcl.codec.utf8String.toBits(f));g=new sjcl.cipher[b.cipher](a);f="ccm"===
b.mode&&sjcl.arrayBuffer&&sjcl.arrayBuffer.ccm&&b.ct instanceof ArrayBuffer?sjcl.arrayBuffer.ccm.decrypt(g,b.ct,b.iv,b.tag,f,b.ts):sjcl.mode[b.mode].decrypt(g,b.ct,b.iv,f,b.ts);e.m(d,b);d.key=a;return 1===c.raw?f:sjcl.codec.utf8String.fromBits(f)},decrypt:function(a,b,c,d){var e=sjcl.json;return e.ja(a,e.decode(b),c,d)},encode:function(a){var b,c="{",d="";for(b in a)if(a.hasOwnProperty(b)){if(!b.match(/^[a-z0-9]+$/i))throw new sjcl.exception.invalid("json encode: invalid property name");c+=d+'"'+
b+'":';d=",";switch(typeof a[b]){case "number":case "boolean":c+=a[b];break;case "string":c+='"'+escape(a[b])+'"';break;case "object":c+='"'+sjcl.codec.base64.fromBits(a[b],0)+'"';break;default:throw new sjcl.exception.bug("json encode: unsupported type");}}return c+"}"},decode:function(a){a=a.replace(/\s/g,"");if(!a.match(/^\{.*\}$/))throw new sjcl.exception.invalid("json decode: this isn't json!");a=a.replace(/^\{|\}$/g,"").split(/,/);var b={},c,d;for(c=0;c<a.length;c++){if(!(d=a[c].match(/^\s*(?:(["']?)([a-z][a-z0-9]*)\1)\s*:\s*(?:(-?\d+)|"([a-z0-9+\/%*_.@=\-]*)"|(true|false))$/i)))throw new sjcl.exception.invalid("json decode: this isn't json!");
null!=d[3]?b[d[2]]=parseInt(d[3],10):null!=d[4]?b[d[2]]=d[2].match(/^(ct|adata|salt|iv)$/)?sjcl.codec.base64.toBits(d[4]):unescape(d[4]):null!=d[5]&&(b[d[2]]="true"===d[5])}return b},m:function(a,b,c){void 0===a&&(a={});if(void 0===b)return a;for(var d in b)if(b.hasOwnProperty(d)){if(c&&void 0!==a[d]&&a[d]!==b[d])throw new sjcl.exception.invalid("required parameter overridden");a[d]=b[d]}return a},va:function(a,b){var c={},d;for(d in a)a.hasOwnProperty(d)&&a[d]!==b[d]&&(c[d]=a[d]);return c},ua:function(a,
b){var c={},d;for(d=0;d<b.length;d++)void 0!==a[b[d]]&&(c[b[d]]=a[b[d]]);return c}};sjcl.encrypt=sjcl.json.encrypt;sjcl.decrypt=sjcl.json.decrypt;sjcl.misc.sa={};sjcl.misc.cachedPbkdf2=function(a,b){var c=sjcl.misc.sa,d;b=b||{};d=b.iter||1E3;c=c[a]=c[a]||{};d=c[d]=c[d]||{firstSalt:b.salt&&b.salt.length?b.salt.slice(0):sjcl.random.randomWords(2,0)};c=void 0===b.salt?d.firstSalt:b.salt;d[c]=d[c]||sjcl.misc.pbkdf2(a,c,b.iter);return{key:d[c].slice(0),salt:c.slice(0)}};
"undefined"!==typeof module&&module.exports&&(module.exports=sjcl);"function"===typeof define&&define([],function(){return sjcl});

145
utils/storage.js Normal file
View file

@ -0,0 +1,145 @@
/**
* 存储相关工具函数
*/
const cloud = require('./cloud');
const eventManager = require('./eventManager');
// 存储键常量
const STORAGE_KEY = 'tokens';
/**
* 从本地存储获取所有令牌
* @returns {Promise<Array>} 令牌列表
*/
const getTokens = () => {
return new Promise((resolve, reject) => {
wx.getStorage({
key: STORAGE_KEY,
success: (res) => resolve(res.data || []),
fail: (err) => {
if (err.errMsg.includes('data not found')) {
resolve([]);
} else {
reject(err);
}
}
});
});
};
/**
* 保存令牌数组到本地存储
* @param {Array} tokens - 令牌数组
* @returns {Promise<void>} 异步操作结果
*/
const saveTokens = (tokens) => {
return new Promise((resolve, reject) => {
wx.setStorage({
key: STORAGE_KEY,
data: tokens,
success: () => resolve(),
fail: (err) => reject(err)
});
});
};
/**
* 添加令牌到本地存储
* @param {Object} tokenData - 令牌数据对象
* @returns {Promise<void>} 异步操作结果
*/
const addToken = async (tokenData) => {
try {
const tokens = await getTokens();
tokens.push(tokenData);
await saveTokens(tokens);
eventManager.emit('tokensUpdated', tokens);
} catch (error) {
throw error;
}
};
/**
* 更新令牌信息
* @param {string} tokenId - 令牌ID
* @param {Object} updates - 要更新的字段
* @returns {Promise<void>} 异步操作结果
*/
const updateToken = async (tokenId, updates) => {
try {
const tokens = await getTokens();
const index = tokens.findIndex(t => t.id === tokenId);
if (index === -1) {
throw new Error('令牌不存在');
}
tokens[index] = { ...tokens[index], ...updates };
await saveTokens(tokens);
} catch (error) {
throw error;
}
};
/**
* 删除令牌
* @param {string} tokenId - 令牌ID
* @returns {Promise<void>} 异步操作结果
*/
const deleteToken = async (tokenId) => {
try {
const tokens = await getTokens();
const newTokens = tokens.filter(t => t.id !== tokenId);
await saveTokens(newTokens);
} catch (error) {
throw error;
}
};
/**
* 备份令牌到云端
* @returns {Promise<string>} 云端数据ID
*/
const backupToCloud = async () => {
try {
const tokens = await getTokens();
return await cloud.uploadTokens(tokens);
} catch (error) {
throw error;
}
};
/**
* 从云端恢复令牌
* @param {Object} options - 恢复选项
* @param {boolean} [options.merge=false] - 是否合并数据而不是覆盖
* @param {boolean} [options.preferCloud=true] - 合并时是否优先使用云端数据
* @returns {Promise<void>} 异步操作结果
*/
const restoreFromCloud = async (options = { merge: false, preferCloud: true }) => {
try {
const cloudData = await cloud.fetchLatestTokens();
const cloudTokens = cloudData.tokens;
if (options.merge) {
const localTokens = await getTokens();
const mergedTokens = cloud.mergeTokens(localTokens, cloudTokens, {
preferCloud: options.preferCloud
});
await saveTokens(mergedTokens);
} else {
await saveTokens(cloudTokens);
}
} catch (error) {
throw error;
}
};
module.exports = {
getTokens,
addToken,
updateToken,
deleteToken,
backupToCloud,
restoreFromCloud
};

259
utils/totp.js Normal file
View file

@ -0,0 +1,259 @@
const { hmac } = require('./hmac.js');
const { decode: base32Decode } = require('./base32.js');
const { constantTimeEqual, safeIntegerParse } = require('./crypto.js');
// 支持的哈希算法
const HASH_ALGOS = {
'SHA1': 'SHA1',
'SHA256': 'SHA256',
'SHA512': 'SHA512'
};
// 默认配置
const DEFAULT_CONFIG = {
algorithm: HASH_ALGOS.SHA1, // 默认使用SHA1
period: 30, // 默认30秒时间窗口
digits: 6, // 默认6位数字
timestamp: null, // 默认使用当前时间
window: 1 // 默认验证前后1个时间窗口
};
/**
* 生成TOTP值
*
* @param {string} secret - Base32编码的密钥
* @param {Object} options - 配置选项
* @param {string} [options.algorithm='SHA1'] - 使用的哈希算法SHA1/SHA256/SHA512
* @param {number} [options.period=30] - 时间窗口大小
* @param {number} [options.digits=6] - 生成的OTP位数
* @param {number} [options.timestamp=null] - 指定时间戳null表示使用当前时间
* @returns {Promise<string>} 生成的TOTP值
* @throws {Error} 参数无效时抛出错误
*/
async function generateTOTP(secret, options = {}) {
// 验证密钥
if (!secret || typeof secret !== 'string') {
throw new Error('Secret must be a non-empty string');
}
// 合并配置
const config = { ...DEFAULT_CONFIG, ...options };
// 验证算法
if (!HASH_ALGOS[config.algorithm]) {
throw new Error(`Unsupported algorithm: ${config.algorithm}`);
}
// 验证时间窗口
const period = safeIntegerParse(config.period, 1, 300);
if (period === null) {
throw new Error('Period must be an integer between 1 and 300');
}
// 验证位数
const digits = safeIntegerParse(config.digits, 6, 8);
if (digits === null) {
throw new Error('Digits must be an integer between 6 and 8');
}
// 获取时间戳
const timestamp = config.timestamp === null
? Math.floor(Date.now() / 1000)
: config.timestamp;
if (!Number.isInteger(timestamp) || timestamp < 0) {
throw new Error('Invalid timestamp');
}
try {
// 解码密钥
const key = base32Decode(secret);
// 计算时间计数器
const counter = Math.floor(timestamp / period);
// 生成8字节的计数器缓冲区
const counterBuffer = new ArrayBuffer(8);
const counterView = new DataView(counterBuffer);
counterView.setBigInt64(0, BigInt(counter), false); // big-endian
// 计算HMAC
const hmacInput = new Uint8Array(counterBuffer);
const hash = await hmac(config.algorithm, key, hmacInput);
// 根据RFC 6238获取偏移量
const offset = hash[hash.length - 1] & 0xf;
// 生成4字节的动态截断数
const binary = ((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
// 生成指定位数的OTP
const otp = binary % Math.pow(10, digits);
// 补齐前导零
return otp.toString().padStart(digits, '0');
} catch (error) {
throw new Error(`Failed to generate TOTP: ${error.message}`);
}
}
/**
* 验证TOTP值
*
* @param {string} token - 要验证的TOTP值
* @param {string} secret - Base32编码的密钥
* @param {Object} options - 配置选项
* @param {string} [options.algorithm='SHA1'] - 使用的哈希算法SHA1/SHA256/SHA512
* @param {number} [options.period=30] - 时间窗口大小
* @param {number} [options.digits=6] - TOTP位数
* @param {number} [options.timestamp=null] - 指定时间戳null表示使用当前时间
* @param {number} [options.window=1] - 验证窗口大小前后几个时间周期
* @returns {Promise<boolean>} 验证是否通过
* @throws {Error} 参数无效时抛出错误
*/
async function verifyTOTP(token, secret, options = {}) {
// 验证token
if (!token || typeof token !== 'string') {
throw new Error('Token must be a non-empty string');
}
// 合并配置
const config = { ...DEFAULT_CONFIG, ...options };
// 验证窗口大小
const window = safeIntegerParse(config.window, 0, 10);
if (window === null) {
throw new Error('Window must be an integer between 0 and 10');
}
// 验证token长度
if (token.length !== config.digits) {
return false;
}
// 验证token是否为纯数字
if (!/^\d+$/.test(token)) {
return false;
}
try {
const timestamp = config.timestamp === null
? Math.floor(Date.now() / 1000)
: config.timestamp;
// 检查前后window个时间窗口
for (let i = -window; i <= window; i++) {
const checkTime = timestamp + (i * config.period);
const generatedToken = await generateTOTP(secret, {
...config,
timestamp: checkTime
});
// 使用常量时间比较
if (constantTimeEqual(token, generatedToken)) {
return true;
}
}
return false;
} catch (error) {
throw new Error(`Failed to verify TOTP: ${error.message}`);
}
}
/**
* 获取当前TOTP的剩余有效时间
*
* @param {Object} options - 配置选项
* @param {number} [options.period=30] - 时间窗口大小
* @param {number} [options.timestamp=null] - 指定时间戳null表示使用当前时间
* @returns {number} 剩余秒数
* @throws {Error} 参数无效时抛出错误
*/
function getRemainingSeconds(options = {}) {
const config = { ...DEFAULT_CONFIG, ...options };
// 验证时间窗口
const period = safeIntegerParse(config.period, 1, 300);
if (period === null) {
throw new Error('Period must be an integer between 1 and 300');
}
const timestamp = config.timestamp === null
? Math.floor(Date.now() / 1000)
: config.timestamp;
if (!Number.isInteger(timestamp) || timestamp < 0) {
throw new Error('Invalid timestamp');
}
// 返回从(period-1)到0的倒计时而不是从period到1
return (period - (timestamp % period) - 1 + period) % period;
}
/**
* 生成TOTP URI
* 遵循 otpauth:// 规范
*
* @param {string} secret - Base32编码的密钥
* @param {string} accountName - 账户名称
* @param {string} issuer - 发行者名称
* @param {Object} options - 配置选项
* @param {string} [options.algorithm='SHA1'] - 使用的哈希算法SHA1/SHA256/SHA512
* @param {number} [options.period=30] - 时间窗口大小
* @param {number} [options.digits=6] - TOTP位数
* @returns {string} TOTP URI
* @throws {Error} 参数无效时抛出错误
*/
function generateTOTPUri(secret, accountName, issuer, options = {}) {
if (!secret || typeof secret !== 'string') {
throw new Error('Secret must be a non-empty string');
}
if (!accountName || typeof accountName !== 'string') {
throw new Error('Account name must be a non-empty string');
}
if (!issuer || typeof issuer !== 'string') {
throw new Error('Issuer must be a non-empty string');
}
const config = { ...DEFAULT_CONFIG, ...options };
// 验证算法
if (!HASH_ALGOS[config.algorithm]) {
throw new Error(`Unsupported algorithm: ${config.algorithm}`);
}
// 验证时间窗口
const period = safeIntegerParse(config.period, 1, 300);
if (period === null) {
throw new Error('Period must be an integer between 1 and 300');
}
// 验证位数
const digits = safeIntegerParse(config.digits, 6, 8);
if (digits === null) {
throw new Error('Digits must be an integer between 6 and 8');
}
// 构建参数
const params = new URLSearchParams({
secret: secret,
issuer: issuer,
algorithm: config.algorithm,
digits: config.digits.toString(),
period: config.period.toString()
});
// 生成URI
// 注意需要对issuer和accountName进行URI编码
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`;
return `otpauth://totp/${label}?${params.toString()}`;
}
module.exports = {
generateTOTP,
verifyTOTP,
getRemainingSeconds,
generateTOTPUri,
HASH_ALGOS
};

132
utils/totp.test.js Normal file
View file

@ -0,0 +1,132 @@
const totp = require('./totp');
console.log('=== TOTP Implementation Test Suite ===\n');
function runTests() {
// 1. Basic Functionality Tests
console.log('1. Basic Functionality Tests:');
const testSecret = 'JBSWY3DPEHPK3PXP';
const testTime = 1623456789000; // Fixed timestamp for consistent testing
try {
const code = totp.generate(testTime, testSecret);
console.log('Generate TOTP code:');
console.log(` Secret: ${testSecret}`);
console.log(` Time: ${new Date(testTime).toISOString()}`);
console.log(` Code: ${code}`);
console.log(` Length: ${code.length} digits`);
console.log(' Result: ✓ (Generated successfully)\n');
} catch (error) {
console.log(` Result: ✗ (${error.message})\n`);
}
// 2. Verification Tests
console.log('2. Verification Tests:');
const currentCode = totp.now(testSecret);
const verifyResults = [
totp.verify(currentCode, testSecret),
totp.verify('000000', testSecret),
totp.verify(currentCode, 'WRONGSECRET')
];
console.log('Verify current code:');
console.log(` Code: ${currentCode}`);
console.log(` Valid code: ${verifyResults[0] ? '✓' : '✗'}`);
console.log(` Invalid code: ${!verifyResults[1] ? '✓' : '✗'}`);
console.log(` Wrong secret: ${!verifyResults[2] ? '✓' : '✗'}\n`);
// 3. Configuration Tests
console.log('3. Configuration Tests:');
const configs = [
{ digits: 8 },
{ interval: 60 },
{ algorithm: 'SHA-256' }
];
configs.forEach(config => {
try {
const code = totp.generate(testTime, testSecret, config);
console.log(`Custom config (${Object.keys(config)[0]}=${Object.values(config)[0]}):`);
console.log(` Code: ${code}`);
console.log(' Result: ✓\n');
} catch (error) {
console.log(`Custom config (${Object.keys(config)[0]}=${Object.values(config)[0]}):`);
console.log(` Result: ✗ (${error.message})\n`);
}
});
// 4. Error Handling Tests
console.log('4. Error Handling Tests:');
const errorTests = [
['Invalid secret', () => totp.generate(testTime, '')],
['Invalid digits', () => totp.generate(testTime, testSecret, { digits: 4 })],
['Invalid interval', () => totp.generate(testTime, testSecret, { interval: -1 })],
['Invalid algorithm', () => totp.generate(testTime, testSecret, { algorithm: 'INVALID' })]
];
errorTests.forEach(([name, test]) => {
try {
test();
console.log(`${name}: ✗ (Should have thrown)`);
} catch (error) {
console.log(`${name}: ✓ (${error.type})`);
}
});
console.log('');
// 5. URI Generation Test
console.log('5. URI Generation Test:');
try {
const uri = totp.generateUri(
testSecret,
'test@example.com',
'TestApp',
{ digits: 8, interval: 60 }
);
console.log('Generated URI:');
console.log(` ${uri}`);
console.log(' Result: ✓\n');
} catch (error) {
console.log(` Result: ✗ (${error.message})\n`);
}
// 6. Time Window Tests
console.log('6. Time Window Tests:');
const remaining = totp.timeRemaining();
console.log(`Time until next code: ${remaining} seconds`);
// Test window verification
const currentTime = Date.now();
const pastCode = totp.generate(currentTime - 30000, testSecret);
const futureCode = totp.generate(currentTime + 30000, testSecret);
console.log('Time window verification:');
console.log(` Past code valid: ${totp.verify(pastCode, testSecret, { window: 1 }) ? '✓' : '✗'}`);
console.log(` Future code valid: ${totp.verify(futureCode, testSecret, { window: 1 }) ? '✓' : '✗'}`);
console.log(` Outside window invalid: ${!totp.verify(pastCode, testSecret, { window: 0 }) ? '✓' : '✗'}\n`);
// 7. Security Tests
console.log('7. Security Tests:');
// Test timing attack resistance
const start = Date.now();
for (let i = 0; i < 1000; i++) {
totp.verify('000000', testSecret);
totp.verify('999999', testSecret);
}
const end = Date.now();
const timeDiff = Math.abs((end - start) / 2000);
console.log('Timing attack resistance:');
console.log(` Average verification time: ${timeDiff.toFixed(3)}ms`);
console.log(` Time consistency: ${timeDiff < 1 ? '✓' : '✗'}`);
}
// Run all tests
runTests();

76
utils/ui.js Normal file
View file

@ -0,0 +1,76 @@
/**
* 显示加载提示
* @param {string} [title='加载中'] - 提示文本
* @param {boolean} [mask=true] - 是否显示透明蒙层
*/
export const showLoading = (title = '加载中', mask = true) => {
wx.showLoading({
title,
mask
});
};
/**
* 隐藏加载提示
*/
export const hideLoading = () => {
wx.hideLoading();
};
/**
* 显示Toast提示
* @param {string} title - 提示内容
* @param {string} [icon='none'] - 图标类型
* @param {number} [duration=1500] - 显示时长(毫秒)
*/
export const showToast = (title, icon = 'none', duration = 1500) => {
return new Promise((resolve) => {
wx.showToast({
title,
icon,
duration,
success: resolve
});
});
};
/**
* 显示确认对话框
* @param {string} content - 对话框内容
* @param {Object} [options] - 配置选项
* @param {string} [options.title='提示'] - 对话框标题
* @param {string} [options.confirmText='确定'] - 确认按钮文本
* @param {string} [options.cancelText='取消'] - 取消按钮文本
* @param {boolean} [options.showCancel=true] - 是否显示取消按钮
* @returns {Promise<boolean>} 用户是否点击了确认
*/
export const showConfirmModal = (content, options = {}) => {
return new Promise((resolve) => {
wx.showModal({
title: options.title || '提示',
content,
confirmText: options.confirmText || '确定',
cancelText: options.cancelText || '取消',
showCancel: options.showCancel !== false,
success: (res) => resolve(res.confirm),
fail: () => resolve(false)
});
});
};
/**
* 显示操作菜单
* @param {Array<string>} itemList - 菜单项列表
* @param {string} [itemColor='#000000'] - 菜单项颜色
* @returns {Promise<number>} 用户选择的索引取消时为-1
*/
export const showActionSheet = (itemList, itemColor = '#000000') => {
return new Promise((resolve) => {
wx.showActionSheet({
itemList,
itemColor,
success: (res) => resolve(res.tapIndex),
fail: () => resolve(-1)
});
});
};

343
utils/util.js Normal file
View file

@ -0,0 +1,343 @@
/**
* 统一工具接口
* 所有外部模块只能通过此文件访问功能
*/
const otp = require('./otp');
const storage = require('./storage');
const format = require('./format');
const cloud = require('./cloud');
const ui = require('./ui');
const base32 = require('./base32');
const crypto = require('./crypto');
const eventManager = require('./eventManager');
/**
* 验证token是否在当前时间窗口内
* @param {Object} token - 令牌对象
* @param {number} timestamp - 当前时间戳()
* @param {number} period - TOTP周期()
* @returns {boolean} 是否在同一时间窗口
*/
function verifyTokenWindow(token, timestamp, period) {
if (!token || !token.timestamp) return false;
const currentCounter = Math.floor(timestamp / period);
const tokenCounter = Math.floor(token.timestamp / period);
return currentCounter === tokenCounter;
}
// ============ OTP相关功能 ============
/**
* 生成当前时间的验证码
* @param {Object} config - 配置对象
* @param {string} config.type - OTP类型 ('totp' 'hotp')
* @param {string} config.secret - Base32编码的密钥
* @param {string} [config.algorithm='SHA1'] - 使用的哈希算法
* @param {number} [config.period=30] - TOTP的时间周期
* @param {number} [config.digits=6] - OTP的位数
* @param {number} [config.counter] - HOTP的计数器值
* @param {boolean} [config._forceRefresh=false] - 是否强制刷新
* @returns {Promise<string>} 生成的验证码
*/
const generateCode = async (config) => {
try {
const options = {
algorithm: config.algorithm || config.algo || 'SHA1',
digits: Number(config.digits) || 6,
_forceRefresh: !!config._forceRefresh,
timestamp: config.timestamp || otp.getCurrentTimestamp()
};
if (config.type === 'otpauth') {
// 智能处理otpauth类型
if (!config.secret) {
throw new Error('otpauth类型必须提供secret参数');
}
if (!config.secret.startsWith('otpauth://')) {
// 如果不是otpauth URI格式自动转换为TOTP处理
config.type = 'totp';
}
}
if (config.type === 'totp') {
options.period = Number(config.period) || 30;
// 如果提供了token对象且不需要强制刷新验证时间窗口
if (config.token && !options._forceRefresh) {
if (verifyTokenWindow(config.token, options.timestamp, options.period)) {
return config.token.code; // 仍在同一窗口返回缓存的code
}
}
} else if (config.type === 'hotp') {
if (config.counter === undefined) {
throw new Error('HOTP需要计数器值');
}
options.counter = Number(config.counter);
} else {
throw new Error('不支持的OTP类型');
}
return await otp.generate(config.type, config.secret, options);
} catch (error) {
console.error('[Error] Failed to generate code:', error);
throw error;
}
};
/**
* 验证OTP码
* @param {string} token - 要验证的OTP码
* @param {Object} config - 配置对象与generateCode相同
* @returns {Promise<boolean>} 验证结果
*/
const verifyCode = async (token, config) => {
try {
const options = {
algorithm: config.algorithm || config.algo || 'SHA1',
digits: Number(config.digits) || 6,
timestamp: otp.getCurrentTimestamp() // 使用统一的时间戳获取方法
};
if (config.type === 'otpauth') {
// otpauth URI由底层otp.verify处理
} else if (config.type === 'totp') {
options.period = Number(config.period) || 30;
} else if (config.type === 'hotp') {
if (config.counter === undefined) {
throw new Error('HOTP需要计数器值');
}
options.counter = Number(config.counter);
} else {
throw new Error('不支持的OTP类型');
}
return await otp.verify(token, config.type, config.secret, options);
} catch (error) {
console.error('[Error] Failed to verify code:', error);
throw error;
}
};
/**
* 获取TOTP剩余秒数
* @param {number} [period=30] - TOTP周期
* @returns {number} 剩余秒数
*/
const getRemainingSeconds = (period = 30) => {
try {
// 使用otp模块的getRemainingSeconds它已经使用了getCurrentTimestamp
return otp.getRemainingSeconds({ period });
} catch (error) {
console.error('[Error] Failed to get remaining seconds:', error);
throw error;
}
};
// ============ 令牌管理相关功能 ============
/**
* 添加新令牌
* @param {Object} tokenData - 令牌数据
* @returns {Promise<void>}
*/
const addToken = async (tokenData) => {
// 验证令牌数据
const errors = format.validateToken(tokenData);
if (errors) {
throw new Error(errors.join('; '));
}
// 生成唯一ID
const id = `token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const token = {
...tokenData,
id,
createTime: formatTime(new Date())
};
// 验证是否可以生成代码
try {
await generateCode(token);
} catch (error) {
throw new Error('无效的令牌配置: ' + error.message);
}
// 保存令牌
await storage.addToken(token);
};
/**
* 从URL添加令牌
* @param {string} url - OTP URL
* @returns {Promise<void>}
*/
const addTokenFromUrl = async (url) => {
const tokenData = format.parseURL(url);
if (!tokenData) {
throw new Error('无效的OTP URL');
}
await addToken(tokenData);
};
/**
* 获取所有令牌
* @returns {Promise<Array>}
*/
const getTokens = async () => {
return await storage.getTokens();
};
/**
* 更新令牌
* @param {string} tokenId - 令牌ID
* @param {Object} updates - 更新的字段
* @returns {Promise<void>}
*/
const updateToken = async (tokenId, updates) => {
await storage.updateToken(tokenId, updates);
};
/**
* 删除令牌
* @param {string} tokenId - 令牌ID
* @returns {Promise<void>}
*/
const deleteToken = async (tokenId) => {
await storage.deleteToken(tokenId);
};
// ============ 格式化相关功能 ============
/**
* 格式化时间
* @param {Date} date - 日期对象
* @returns {string} 格式化后的时间字符串
*/
const formatTime = (date) => {
return format.formatTime(date);
};
/**
* 格式化日期
* @param {Date} date - 日期对象
* @returns {string} 格式化后的日期字符串
*/
const formatDate = (date) => {
return format.formatDate(date);
};
// ============ 云同步相关功能 ============
/**
* 同步令牌到云端
* @param {Array} tokens - 令牌列表
* @returns {Promise<Array>} 同步后的令牌列表
*/
const syncTokens = async (tokens) => {
return await cloud.syncTokens(tokens);
};
/**
* 从云端获取令牌
* @returns {Promise<Array>} 云端的令牌列表
*/
const getCloudTokens = async () => {
return await cloud.getTokens();
};
// ============ UI相关功能 ============
/**
* 显示加载提示
* @param {string} [title='加载中'] - 提示文字
*/
const showLoading = (title = '加载中') => {
ui.showLoading(title);
};
/**
* 隐藏加载提示
*/
const hideLoading = () => {
ui.hideLoading();
};
/**
* 显示提示信息
* @param {string} title - 提示文字
* @param {string} [icon='none'] - 图标类型
*/
const showToast = (title, icon = 'none') => {
ui.showToast(title, icon);
};
// ============ 加密相关功能 ============
/**
* Base32编码
* @param {string|Uint8Array} input - 输入数据
* @returns {string} Base32编码字符串
*/
const encodeBase32 = (input) => {
return base32.encode(input);
};
/**
* Base32解码
* @param {string} input - Base32编码字符串
* @returns {Uint8Array} 解码后的数据
*/
const decodeBase32 = (input) => {
return base32.decode(input);
};
/**
* 生成随机密钥
* @param {number} [length=20] - 密钥长度字节
* @returns {string} Base32编码的随机密钥
*/
const generateSecret = (length = 20) => {
return crypto.generateSecret(length);
};
// 导出所有功能
// 从format模块重新导出验证和解析函数
const { validateToken, parseURL } = require('./format');
module.exports = {
// OTP相关
generateCode,
verifyCode,
getRemainingSeconds,
// 令牌管理
addToken,
addTokenFromUrl,
getTokens,
updateToken,
deleteToken,
// 格式化
validateToken, // 添加验证函数
parseURL, // 添加解析函数
formatTime,
formatDate,
// 云同步
syncTokens,
getCloudTokens,
// UI
showLoading,
hideLoading,
showToast,
// 加密
encodeBase32,
decodeBase32,
generateSecret,
// 事件管理
eventManager
};