feat: 优化Microsoft Authenticator数据导入
This commit is contained in:
1 parent
733972a5ad
commit
0dc02271e3
18 files changed
+667
-103
No files matched your search
@@ -20,4 +20,4 @@
|
||||
"overrides": {
|
||||
"esbuild": ">=0.25.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const CSP_POLICY = {
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"'unsafe-eval'",
|
||||
"'wasm-unsafe-eval'",
|
||||
"https://static.cloudflareinsights.com",
|
||||
],
|
||||
// 图片源: 允许本站、GitHub 头像、NodeLoc 头像
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"name": "2fauth-worker-demo",
|
||||
"main": "src/app/index.ts",
|
||||
"compatibility_date": "2024-04-01",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
// ====== 静态资源托管 ======
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"name": "2fauth-worker-dev",
|
||||
"main": "src/app/index.ts",
|
||||
"compatibility_date": "2024-04-01",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
// ====== 静态资源托管 ======
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"name": "2fauth-worker",
|
||||
"main": "src/app/index.ts",
|
||||
"compatibility_date": "2024-04-01",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
// ====== 静态资源托管 ======
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"scrypt-js": "^3.0.1",
|
||||
"sql.js": "^1.10.3",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
"vue-router": "^5.0.3",
|
||||
"wa-sqlite": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
|
||||
@@ -209,10 +209,11 @@ main {
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 30px 20px;
|
||||
width: 100%;
|
||||
background-color: var(--el-bg-color-page);
|
||||
overflow-y: auto;
|
||||
/* 原为 auto,修改为保留滚动空间防止因高度变化产生的抖动,或直接将滚动条悬浮覆盖 */
|
||||
overflow-y: overlay;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
@@ -428,6 +429,14 @@ main {
|
||||
letter-spacing: 2px;
|
||||
line-height: 1;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.current-code[data-digits="8"] {
|
||||
font-size: 20px;
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
.next-code {
|
||||
@@ -436,6 +445,11 @@ main {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.next-code[data-digits="8"] {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
.timer-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
@@ -674,16 +688,6 @@ main {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1065,7 +1069,6 @@ main {
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
margin-bottom: 20px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
@@ -1073,7 +1076,7 @@ main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -1168,8 +1171,13 @@ main {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* 修复 el-dialog 在移动端固定宽溢出 */
|
||||
.el-dialog {
|
||||
width: 90% !important;
|
||||
}
|
||||
|
||||
.tab-card-wrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,38 @@ export function useDataImport(emitFn) {
|
||||
batchAccumulatedVault.value = []
|
||||
batchErrors.value = []
|
||||
batchCurrentTaskName.value = ''
|
||||
phoneFactorGroup.value = { main: null, wal: null, shm: null }
|
||||
}
|
||||
|
||||
// PhoneFactor specialized grouping
|
||||
const phoneFactorGroup = ref({ main: null, wal: null, shm: null })
|
||||
let phoneFactorTimer = null
|
||||
|
||||
const processPhoneFactorGroup = async () => {
|
||||
const group = { ...phoneFactorGroup.value }
|
||||
phoneFactorGroup.value = { main: null, wal: null, shm: null } // reset
|
||||
|
||||
let groupedCount = 0;
|
||||
if (group.main) groupedCount++;
|
||||
if (group.wal) groupedCount++;
|
||||
if (group.shm) groupedCount++;
|
||||
|
||||
// We only proceed if we have at least *something*
|
||||
if (groupedCount === 0) return
|
||||
|
||||
batchCurrentTaskName.value = '构建数据:PhoneFactor 数据集'
|
||||
try {
|
||||
// we pass the entire group object to the service
|
||||
const vault = await dataMigrationService.parseImportData(group, 'phonefactor_group')
|
||||
batchAccumulatedVault.value.push(...vault)
|
||||
} catch (err) {
|
||||
console.error('[PhoneFactor Group] parseImportData failed:', err)
|
||||
const detail = err && err.message ? err.message : String(err)
|
||||
batchErrors.value.push(`[PhoneFactor] 解析失败: ${detail}`)
|
||||
} finally {
|
||||
batchProcessedJobs.value += groupedCount
|
||||
importingJobs.value -= groupedCount
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (uploadFile) => {
|
||||
@@ -96,6 +128,31 @@ export function useDataImport(emitFn) {
|
||||
|
||||
const handleNonImageFile = (file) => {
|
||||
try {
|
||||
// First pass check for PhoneFactor by filename, buffer them
|
||||
const fname = file.name.toLowerCase()
|
||||
if (fname === 'phonefactor' || fname === 'phonefactor-wal' || fname === 'phonefactor-shm') {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const buffer = event.target.result
|
||||
if (fname === 'phonefactor') phoneFactorGroup.value.main = { name: file.name, buffer }
|
||||
else if (fname === 'phonefactor-wal') phoneFactorGroup.value.wal = { name: file.name, buffer }
|
||||
else if (fname === 'phonefactor-shm') phoneFactorGroup.value.shm = { name: file.name, buffer }
|
||||
|
||||
// Debounce group execution
|
||||
if (phoneFactorTimer) clearTimeout(phoneFactorTimer)
|
||||
phoneFactorTimer = setTimeout(() => {
|
||||
processPhoneFactorGroup()
|
||||
}, 500)
|
||||
}
|
||||
reader.onerror = () => {
|
||||
batchErrors.value.push(`[${file.name}] 读取失败`)
|
||||
batchProcessedJobs.value++
|
||||
importingJobs.value--
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
return
|
||||
}
|
||||
|
||||
currentFile.value = file
|
||||
const reader = new FileReader()
|
||||
|
||||
@@ -114,7 +171,6 @@ export function useDataImport(emitFn) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果不是 phonefactor,后续解析需要字符串内容
|
||||
let contentForParse = buffer
|
||||
if (detectedType !== 'phonefactor') {
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,11 @@ import { aegisStrategy } from '@/shared/utils/serializers/aegisStrategy'
|
||||
import protonStrategy from '@/shared/utils/serializers/protonStrategy'
|
||||
import { migrationError } from '@/shared/utils/errors/migrationError'
|
||||
import jsQR from 'jsqr'
|
||||
import initSqlJs from 'sql.js'
|
||||
|
||||
|
||||
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite-async.mjs'
|
||||
import * as SQLite from 'wa-sqlite'
|
||||
import { MemoryAsyncVFS } from '@/shared/utils/sqlite/MemoryAsyncVFS'
|
||||
|
||||
// 2FAS 加密备份的固定参数(官方格式标准)
|
||||
const _2FAS_CRYPTO_CONFIG = {
|
||||
@@ -503,12 +507,16 @@ export const dataMigrationService = {
|
||||
const allBufs = bufs.slice()
|
||||
const permutations = [
|
||||
{ salt: bufs[1], iv: bufs[2], cipher: bufs[0], name: 'bufs[1]=salt, bufs[2]=iv, bufs[0]=cipher' },
|
||||
{ salt: bufs.length > 1 && bufs[1].length >= 44 ? bufs[1].slice(0, 32) : null,
|
||||
iv: bufs.length > 1 && bufs[1].length >= 44 ? bufs[1].slice(32, 44) : null,
|
||||
cipher: bufs[0],
|
||||
name: 'salt/iv extracted from bufs[1]' },
|
||||
{ salt: bufs[0].slice(0, 32), iv: bufs[0].slice(32, 44), cipher: bufs[0].slice(44),
|
||||
name: 'salt/iv extracted from bufs[0]' }
|
||||
{
|
||||
salt: bufs.length > 1 && bufs[1].length >= 44 ? bufs[1].slice(0, 32) : null,
|
||||
iv: bufs.length > 1 && bufs[1].length >= 44 ? bufs[1].slice(32, 44) : null,
|
||||
cipher: bufs[0],
|
||||
name: 'salt/iv extracted from bufs[1]'
|
||||
},
|
||||
{
|
||||
salt: bufs[0].slice(0, 32), iv: bufs[0].slice(32, 44), cipher: bufs[0].slice(44),
|
||||
name: 'salt/iv extracted from bufs[0]'
|
||||
}
|
||||
]
|
||||
|
||||
for (const perm of permutations) {
|
||||
@@ -621,95 +629,284 @@ export const dataMigrationService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 3.0 解析PhoneFactor SQLite数据库
|
||||
* @param {ArrayBuffer} dbBuffer - SQLite数据库二进制数据
|
||||
* 辅助函数:将字节数组编码为 Base32 字符串
|
||||
* @param {Uint8Array} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
bytesToBase32(bytes) {
|
||||
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
let bits = 0
|
||||
let value = 0
|
||||
let output = ''
|
||||
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
value = (value << 8) | bytes[i]
|
||||
bits += 8
|
||||
|
||||
while (bits >= 5) {
|
||||
bits -= 5
|
||||
output += base32Chars[(value >>> bits) & 31]
|
||||
}
|
||||
}
|
||||
|
||||
if (bits > 0) {
|
||||
output += base32Chars[(value << (5 - bits)) & 31]
|
||||
}
|
||||
|
||||
// 添加 padding
|
||||
while (output.length % 8) {
|
||||
output += '='
|
||||
}
|
||||
|
||||
return output
|
||||
},
|
||||
|
||||
/**
|
||||
* 辅助函数:将 Base64 字符串转换为 Base32
|
||||
* @param {string} base64Str
|
||||
* @returns {string}
|
||||
*/
|
||||
base64ToBase32(base64Str) {
|
||||
try {
|
||||
// 解码 Base64 为字节数组
|
||||
const binaryString = atob(base64Str.trim())
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
// 转换为 Base32
|
||||
return this.bytesToBase32(bytes)
|
||||
} catch (e) {
|
||||
throw new Error(`Base64 转 Base32 失败: ${e.message}`)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 3.0 解析PhoneFactor SQLite数据库 (通过 wa-sqlite 支持 WAL 和多文件)
|
||||
* @param {ArrayBuffer|Object} inputData - SQLite数据库二进制数据(为旧版兼容) 或者 包含 main/wal/shm 的 group 对象
|
||||
* @returns {Promise<Object[]>} 标准化的账户列表
|
||||
* @throws {migrationError}
|
||||
*/
|
||||
async parsePhoneFactor(dbBuffer) {
|
||||
async parsePhoneFactor(inputData) {
|
||||
console.log('[PhoneFactor] parsePhoneFactor 开始')
|
||||
let mainBuf = null
|
||||
let walBuf = null
|
||||
let shmBuf = null
|
||||
let vfs = null
|
||||
|
||||
try {
|
||||
// 初始化 sql.js:浏览器端优先从 /sql-wasm.wasm 或 CDN 加载 wasm,Node 环境从 node_modules 加载
|
||||
let SQL
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
SQL = await initSqlJs({ locateFile: file => `/sql-wasm.wasm` })
|
||||
} catch (e) {
|
||||
// 如果本地 public 中不存在 wasm,回退到 CDN
|
||||
SQL = await initSqlJs({ locateFile: file => `https://unpkg.com/sql.js@1.10.3/dist/${file}` })
|
||||
}
|
||||
} else {
|
||||
SQL = await initSqlJs({ locateFile: file => require('path').resolve('node_modules/sql.js/dist/', file) })
|
||||
}
|
||||
} catch (e) {
|
||||
// 最后兜底,直接初始化(可能仍然抛错)
|
||||
SQL = await initSqlJs()
|
||||
// Group 提取
|
||||
|
||||
if (inputData && inputData.main && inputData.main.buffer) {
|
||||
mainBuf = new Uint8Array(inputData.main.buffer)
|
||||
if (inputData.wal && inputData.wal.buffer) walBuf = new Uint8Array(inputData.wal.buffer)
|
||||
if (inputData.shm && inputData.shm.buffer) shmBuf = new Uint8Array(inputData.shm.buffer)
|
||||
} else if (inputData instanceof ArrayBuffer || inputData instanceof Uint8Array) {
|
||||
// 回退:单文件模式
|
||||
mainBuf = new Uint8Array(inputData)
|
||||
} else {
|
||||
throw new migrationError('无法识别传入的 PhoneFactor 数据格式', 'INVALID_PHONEFACTOR_INPUT')
|
||||
}
|
||||
const db = new SQL.Database(new Uint8Array(dbBuffer))
|
||||
|
||||
console.log('[PhoneFactor] 文件提取完成', {
|
||||
mainBufLen: mainBuf?.byteLength,
|
||||
walBufLen: walBuf?.byteLength ?? 0,
|
||||
shmBufLen: shmBuf?.byteLength ?? 0
|
||||
})
|
||||
|
||||
// 初始化 wa-sqlite
|
||||
console.log('[PhoneFactor] 调用 SQLiteESMFactory...')
|
||||
const module = await SQLiteESMFactory()
|
||||
console.log('[PhoneFactor] SQLiteESMFactory 完成,创建 sqlite3...')
|
||||
const sqlite3 = SQLite.Factory(module)
|
||||
console.log('[PhoneFactor] sqlite3 Factory 完成')
|
||||
|
||||
// 注册内存虚拟文件系统 (MemoryAsyncVFS)
|
||||
// wa-sqlite-async build 必须配合 Async VFS 才能正确处理 Asyncify 异步调用
|
||||
vfs = new MemoryAsyncVFS()
|
||||
const vfsName = `vfs-${Date.now()}`
|
||||
vfs.name = vfsName
|
||||
sqlite3.vfs_register(vfs)
|
||||
console.log('[PhoneFactor] VFS 注册完成:', vfsName)
|
||||
|
||||
// 将文件加载入内存文件系统
|
||||
const mainPath = 'PhoneFactor'
|
||||
|
||||
// Helper to write file to MemoryVFS
|
||||
const writeFileToVFS = (path, uint8Array) => {
|
||||
// 使用 slice 确保我们获取的是一个干净的 ArrayBuffer,
|
||||
// 解决可能存在的 TypedArray view 偏移量问题导致 SQLite 无法识别文件头。
|
||||
const ab = uint8Array.buffer.slice(uint8Array.byteOffset, uint8Array.byteOffset + uint8Array.byteLength)
|
||||
vfs.mapNameToFile.set(path, {
|
||||
name: path,
|
||||
flags: SQLite.SQLITE_OPEN_CREATE | SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_MAIN_DB,
|
||||
size: uint8Array.byteLength,
|
||||
data: ab
|
||||
})
|
||||
}
|
||||
|
||||
writeFileToVFS(mainPath, mainBuf)
|
||||
// 注入 WAL(如果有真实内容)
|
||||
const hasRealWal = walBuf && walBuf.byteLength > 0
|
||||
if (hasRealWal) writeFileToVFS(`${mainPath}-wal`, walBuf)
|
||||
// ⚠️ 关键:始终注入 SHM(如果存在),不管 WAL 是否为空。
|
||||
// PhoneFactor 主文件本身可能已处于 WAL 模式(header byte 18 = 2),
|
||||
// SQLite 初始化时必须能找到 SHM 文件,否则报 SQLITE_CANTOPEN (14)。
|
||||
if (shmBuf && shmBuf.byteLength > 0) writeFileToVFS(`${mainPath}-shm`, shmBuf)
|
||||
|
||||
console.log('[PhoneFactor] 注入文件到 VFS 完成', {
|
||||
main: mainPath,
|
||||
hasWal: hasRealWal,
|
||||
hasShm: !!(shmBuf && shmBuf.byteLength > 0)
|
||||
})
|
||||
|
||||
console.log('[PhoneFactor] 调用 open_v2...')
|
||||
const db = await sqlite3.open_v2(
|
||||
mainPath,
|
||||
SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_CREATE,
|
||||
vfsName
|
||||
)
|
||||
console.log('[PhoneFactor] open_v2 成功')
|
||||
|
||||
// ⚠️ 关键步骤:处理 WAL 模式(无论 WAL 文件是否为空)
|
||||
// PhoneFactor 数据库可能已处于 WAL 模式(header byte 18 = 2)。
|
||||
// 即使 WAL 文件为空(无未提交变更),也必须执行以下两条 PRAGMA 来:
|
||||
// 1. 获取 EXCLUSIVE 锁,使 SQLite 能在只有 SHM 的情况下正确初始化
|
||||
// 2. 将 journal 模式切换回 DELETE,触发 checkpoint 并确保数据可读
|
||||
// 注:对于非 WAL 模式数据库,这两条 PRAGMA 是无害的空操作
|
||||
console.log('[PhoneFactor] 执行 PRAGMA locking_mode...')
|
||||
await sqlite3.exec(db, 'PRAGMA locking_mode = EXCLUSIVE;')
|
||||
console.log('[PhoneFactor] 执行 PRAGMA journal_mode...')
|
||||
await sqlite3.exec(db, 'PRAGMA journal_mode = DELETE;')
|
||||
console.log('[PhoneFactor] PRAGMA 完成')
|
||||
|
||||
// 检查accounts表是否存在
|
||||
const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
|
||||
if (tables.length === 0) {
|
||||
let hasAccountsTable = false
|
||||
await sqlite3.exec(db, "SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'", (row) => {
|
||||
hasAccountsTable = true
|
||||
})
|
||||
console.log('[PhoneFactor] accounts 表存在:', hasAccountsTable)
|
||||
|
||||
if (!hasAccountsTable) {
|
||||
await sqlite3.close(db)
|
||||
throw new migrationError('不是有效的Microsoft Authenticator数据文件', 'INVALID_PHONEFACTOR_FILE')
|
||||
}
|
||||
|
||||
// 使用 prepared statement 逐行读取,避免依赖 exec 返回的 columns/values 结构不一致
|
||||
const vault = []
|
||||
let skippedEncrypted = 0
|
||||
let skippedEmpty = 0
|
||||
let skippedInvalidSecret = 0
|
||||
const base32Re = /^[A-Z2-7]+=*$/i
|
||||
|
||||
try {
|
||||
const stmt = db.prepare("SELECT name, username, oath_secret_key, encrypted_oath_secret_key FROM accounts WHERE account_type=0")
|
||||
while (stmt.step()) {
|
||||
const record = stmt.getAsObject()
|
||||
const name = record.name
|
||||
const username = record.username
|
||||
const oath_secret_key = record.oath_secret_key
|
||||
const encrypted_oath_secret_key = record.encrypted_oath_secret_key
|
||||
console.log('[PhoneFactor] 开始提取基础数据...')
|
||||
|
||||
// 跳过条件:name 和 username 同时为空
|
||||
if ((!name || String(name).trim() === '') && (!username || String(username).trim() === '')) {
|
||||
skippedEmpty++
|
||||
continue
|
||||
}
|
||||
// 不使用 for await (const stmt of ...),规避 Asyncify Generator 的协程挂起 bug
|
||||
const str = sqlite3.str_new(db, "SELECT name, username, oath_secret_key, encrypted_oath_secret_key, account_type FROM accounts")
|
||||
let prepared = await sqlite3.prepare_v2(db, sqlite3.str_value(str))
|
||||
|
||||
const secret = (oath_secret_key || '').toString().trim()
|
||||
if (!secret) {
|
||||
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
|
||||
skippedEncrypted++
|
||||
continue
|
||||
if (prepared && prepared.stmt) {
|
||||
const stmt = prepared.stmt
|
||||
console.log('[PhoneFactor] SQL 预编译成功,开始读取行...')
|
||||
|
||||
try {
|
||||
let rowsRead = 0;
|
||||
while (await sqlite3.step(stmt) === SQLite.SQLITE_ROW) {
|
||||
rowsRead++;
|
||||
if (rowsRead % 5 === 0) console.log(`[PhoneFactor] 已读取 ${rowsRead} 行...`);
|
||||
|
||||
const row = sqlite3.row(stmt)
|
||||
const name = row[0]
|
||||
const username = row[1]
|
||||
let oath_secret_key = row[2]
|
||||
const encrypted_oath_secret_key = row[3]
|
||||
const account_type = row[4]
|
||||
|
||||
// 跳过条件:name 和 username 同时为空
|
||||
if ((!name || String(name).trim() === '') && (!username || String(username).trim() === '')) {
|
||||
skippedEmpty++
|
||||
continue
|
||||
}
|
||||
|
||||
let secret = (oath_secret_key || '').toString().trim()
|
||||
if (!secret) {
|
||||
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
|
||||
skippedEncrypted++
|
||||
continue
|
||||
}
|
||||
skippedEmpty++
|
||||
continue
|
||||
}
|
||||
|
||||
let algorithm = 'SHA1'
|
||||
let digits = 6
|
||||
|
||||
// 根据 account_type 处理密钥
|
||||
try {
|
||||
if (account_type === 0) {
|
||||
// account_type=0: Base32 编码
|
||||
} else if (account_type === 1) {
|
||||
// account_type=1: Base64 编码,转换为 Base32
|
||||
secret = this.base64ToBase32(secret)
|
||||
algorithm = 'SHA1'
|
||||
digits = 8
|
||||
} else if (account_type === 2) {
|
||||
// account_type=2: Base32 编码(小写),转大写
|
||||
secret = secret.toUpperCase()
|
||||
algorithm = 'SHA256'
|
||||
digits = 6
|
||||
} else {
|
||||
skippedInvalidSecret++
|
||||
continue
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to convert secret for account_type=${account_type}:`, e.message)
|
||||
skippedInvalidSecret++
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证转换后的密钥是否为有效的 Base32 格式
|
||||
const normalized = secret.replace(/\s+/g, '').replace(/=+$/, '')
|
||||
if (!base32Re.test(normalized)) {
|
||||
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
|
||||
skippedEncrypted++
|
||||
continue
|
||||
}
|
||||
skippedInvalidSecret++
|
||||
continue
|
||||
}
|
||||
|
||||
vault.push({
|
||||
service: name || 'Unknown Service',
|
||||
account: username || 'Unknown Account',
|
||||
secret: secret,
|
||||
algorithm: algorithm,
|
||||
digits: digits,
|
||||
period: 30
|
||||
})
|
||||
}
|
||||
skippedEmpty++
|
||||
continue
|
||||
console.log(`[PhoneFactor] 行读取循环结束,总共提取 ${rowsRead} 行`);
|
||||
} finally {
|
||||
await sqlite3.finalize(stmt)
|
||||
}
|
||||
|
||||
const normalized = secret.replace(/\s+/g, '').replace(/=+$/, '')
|
||||
if (!base32Re.test(normalized)) {
|
||||
if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') {
|
||||
skippedEncrypted++
|
||||
continue
|
||||
}
|
||||
skippedEmpty++
|
||||
continue
|
||||
}
|
||||
|
||||
vault.push({
|
||||
service: name || 'Unknown Service',
|
||||
account: username || 'Unknown Account',
|
||||
secret: secret,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30
|
||||
})
|
||||
}
|
||||
stmt.free()
|
||||
sqlite3.str_finish(str)
|
||||
console.log('[PhoneFactor] 数据提取完成,关闭 DB...')
|
||||
} catch (e) {
|
||||
db.close()
|
||||
await sqlite3.close(db)
|
||||
throw new migrationError('解析 PhoneFactor 数据库失败', 'INVALID_PHONEFACTOR_FILE', e)
|
||||
}
|
||||
|
||||
db.close()
|
||||
await sqlite3.close(db)
|
||||
|
||||
// Clean up memory VFS (by clearing the maps, safer than calling xDelete from JS)
|
||||
try {
|
||||
if (vfs) {
|
||||
vfs.mapNameToFile.clear()
|
||||
vfs.mapIdToFile.clear()
|
||||
}
|
||||
} catch (ignore) { }
|
||||
|
||||
if (vault.length === 0) {
|
||||
if (skippedEncrypted > 0) throw new migrationError('PhoneFactor 文件仅包含加密的密钥,无法在前端导入', 'PHONEFACTOR_ONLY_ENCRYPTED')
|
||||
throw new migrationError('未能从 PhoneFactor 文件中提取到可导入的 TOTP 记录', 'PHONEFACTOR_NO_IMPORTABLE_ROWS')
|
||||
@@ -721,10 +918,15 @@ export const dataMigrationService = {
|
||||
console.error('parsePhoneFactor migrationError:', error)
|
||||
throw error
|
||||
}
|
||||
// 打印详细调试信息到控制台,包含底层错误与缓冲长度
|
||||
// 打印详细调试信息到控制台
|
||||
try {
|
||||
const bufLen = (dbBuffer && (dbBuffer.byteLength || dbBuffer.length)) || 0
|
||||
console.error('parsePhoneFactor failed:', { message: error && error.message, stack: error && error.stack, bufferLength: bufLen, error })
|
||||
const bufLen = (mainBuf && (mainBuf.byteLength || mainBuf.length)) || 0
|
||||
console.error('parsePhoneFactor failed:', {
|
||||
message: error && error.message,
|
||||
stack: error && error.stack,
|
||||
bufferLength: bufLen,
|
||||
error
|
||||
})
|
||||
} catch (logErr) {
|
||||
console.error('parsePhoneFactor failed (logging error):', logErr)
|
||||
}
|
||||
@@ -744,8 +946,8 @@ export const dataMigrationService = {
|
||||
async parseImportData(content, type, password) {
|
||||
let rawVault = []
|
||||
|
||||
// 处理PhoneFactor SQLite数据库
|
||||
if (type === 'phonefactor') {
|
||||
// 处理PhoneFactor SQLite数据库 (包括新版的 phonefactor_group)
|
||||
if (type === 'phonefactor' || type === 'phonefactor_group') {
|
||||
return await this.parsePhoneFactor(content)
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ const isIndeterminate = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.export-groups {
|
||||
max-width: 600px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p style="color: var(--el-text-color-secondary);">支持从各类 2FA 软件或本系统的备份文件中恢复数据。</p>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="max-width: 100%; margin: 0 auto;">
|
||||
|
||||
<!-- 统一的拖拽上传区域 -->
|
||||
<el-upload
|
||||
@@ -51,7 +51,11 @@
|
||||
</div>
|
||||
<div class="ms-tip">
|
||||
<span>Microsoft Authenticator <small>(仅支持 Android)</small></span>
|
||||
<p>Android 手机导出文件 <code>/data/data/com.azure.authenticator/databases/PhoneFactor</code> 后,直接上传</p>
|
||||
<p>Android 手机导出以下文件后,同时上传即可导入:
|
||||
<code>/data/data/com.azure.authenticator/databases/PhoneFactor</code><br />
|
||||
<code>/data/data/com.azure.authenticator/databases/PhoneFactor-wal</code><br />
|
||||
<code>/data/data/com.azure.authenticator/databases/PhoneFactor-shm</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="add-vault-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">⌨️ 手动输入</h2>
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="max-width: 100%; margin: 0 auto;">
|
||||
<el-form :model="newVault" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
|
||||
<el-form-item label="服务名称 (如 Google, GitHub)" prop="service">
|
||||
<el-input v-model="newVault.service" placeholder="请输入服务名称" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="add-vault-wrapper">
|
||||
<div class="tab-card-wrapper">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">📷 扫码添加</h2>
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="max-width: 100%; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
|
||||
<p style="color: var(--el-text-color-secondary);">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
direction="ltr"
|
||||
size="240px"
|
||||
:with-header="false"
|
||||
:lock-scroll="false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<el-menu
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
>
|
||||
<el-row :gutter="20" v-if="vault.length > 0">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="vaultItem in vault" :key="vaultItem.id" style="margin-bottom: 20px;">
|
||||
<el-card class="vault-card" :class="{ 'is-selected': selectedIds.includes(vaultItem.id) }" shadow="hover" @click="copyCode(vaultItem)">
|
||||
<el-card class="vault-card" :class="{ 'is-selected': selectedIds.includes(vaultItem.id) }" shadow="hover">
|
||||
<div class="card-header">
|
||||
<div class="service-info">
|
||||
<el-checkbox :model-value="selectedIds.includes(vaultItem.id)" @change="toggleSelection(vaultItem.id)" @click.stop />
|
||||
@@ -80,10 +80,10 @@
|
||||
|
||||
<p class="vault-name">{{ vaultItem.account?.includes(':') ? vaultItem.account.split(':').pop() : vaultItem.account }}</p>
|
||||
|
||||
<div class="code-display-area">
|
||||
<div class="code-display-area" @click="copyCode(vaultItem)">
|
||||
<div class="code-left">
|
||||
<div class="current-code">{{ vaultItem.currentCode || '------' }}</div>
|
||||
<div class="next-code" v-if="vaultItem.nextCode">
|
||||
<div class="current-code" :data-digits="vaultItem.digits">{{ vaultItem.currentCode || '------' }}</div>
|
||||
<div class="next-code" :data-digits="vaultItem.digits" v-if="vaultItem.nextCode">
|
||||
{{ vaultItem.nextCode }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,22 @@ export function useVaultList(afterLoadRef = null) {
|
||||
const merged = flatVault.map(newAcc => {
|
||||
const existing = vault.value.find(a => a.id === newAcc.id)
|
||||
if (existing) {
|
||||
// 检测关键参数变化:digits, secret, algorithm
|
||||
const hasParameterChange =
|
||||
existing.digits !== newAcc.digits ||
|
||||
existing.secret !== newAcc.secret ||
|
||||
existing.algorithm !== newAcc.algorithm
|
||||
|
||||
// 若参数变化,强制清空验证码以触发重新计算
|
||||
if (hasParameterChange) {
|
||||
return {
|
||||
...existing,
|
||||
...newAcc,
|
||||
currentCode: '------', // 强制重新计算
|
||||
forceCompute: true // 标记为需要优先计算
|
||||
}
|
||||
}
|
||||
|
||||
return { ...existing, ...newAcc } // 保留 currentCode / remaining 等动态字段
|
||||
}
|
||||
return {
|
||||
@@ -82,7 +98,7 @@ export function useVaultList(afterLoadRef = null) {
|
||||
})
|
||||
|
||||
// 两级渲染策略,兼顾「无 ------ 闪烁」与「切换回来秒开」:
|
||||
// 1. 找出真正的新增项(已有项 currentCode 有值,直接复用,无需重算)
|
||||
// 1. 找出真正的新增项或参数变化项(已有项 currentCode 有值,直接复用,无需重算)
|
||||
const newItems = merged.filter(acc => !acc.currentCode || acc.currentCode === '------')
|
||||
|
||||
if (newItems.length > 0) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2021 Roy T. Hashimoto. All Rights Reserved.
|
||||
// Copied from wa-sqlite examples and adapted for project use.
|
||||
import { MemoryVFS } from './MemoryVFS.js';
|
||||
|
||||
// Asynchronous memory filesystem. This filesystem requires an Asyncify
|
||||
// build. It is mainly useful for testing that the Asyncify build is
|
||||
// working.
|
||||
export class MemoryAsyncVFS extends MemoryVFS {
|
||||
name = 'memory-async';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async close() {
|
||||
for (const fileId of this.mapIdToFile.keys()) {
|
||||
await this.xClose(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string?} name
|
||||
* @param {number} fileId
|
||||
* @param {number} flags
|
||||
* @param {DataView} pOutFlags
|
||||
* @returns {number}
|
||||
*/
|
||||
xOpen(name, fileId, flags, pOutFlags) {
|
||||
return this.handleAsync(async () => super.xOpen(name, fileId, flags, pOutFlags));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @returns {number}
|
||||
*/
|
||||
xClose(fileId) {
|
||||
return this.handleAsync(async () => super.xClose(fileId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @param {Uint8Array} pData
|
||||
* @param {number} iOffset
|
||||
* @returns {number}
|
||||
*/
|
||||
xRead(fileId, pData, iOffset) {
|
||||
return this.handleAsync(async () => super.xRead(fileId, pData, iOffset));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @param {Uint8Array} pData
|
||||
* @param {number} iOffset
|
||||
* @returns {number}
|
||||
*/
|
||||
xWrite(fileId, pData, iOffset) {
|
||||
return this.handleAsync(async () => super.xWrite(fileId, pData, iOffset));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @param {number} iSize
|
||||
* @returns {number}
|
||||
*/
|
||||
xTruncate(fileId, iSize) {
|
||||
return this.handleAsync(async () => super.xTruncate(fileId, iSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @param {DataView} pSize64
|
||||
* @returns {number}
|
||||
*/
|
||||
xFileSize(fileId, pSize64) {
|
||||
return this.handleAsync(async () => super.xFileSize(fileId, pSize64));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {number} syncDir
|
||||
* @returns {number}
|
||||
*/
|
||||
xDelete(name, syncDir) {
|
||||
return this.handleAsync(async () => super.xDelete(name, syncDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number} flags
|
||||
* @param {DataView} pResOut
|
||||
* @returns {number}
|
||||
*/
|
||||
xAccess(name, flags, pResOut) {
|
||||
return this.handleAsync(async () => super.xAccess(name, flags, pResOut));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Copyright 2021 Roy T. Hashimoto. All Rights Reserved.
|
||||
// Copied from wa-sqlite examples and adapted for project use.
|
||||
import * as VFS from 'wa-sqlite/src/VFS.js';
|
||||
|
||||
// Memory filesystem. Although this is mainly provided as an example
|
||||
// for new VFS classes, it seems to be faster than the default filesystem.
|
||||
export class MemoryVFS extends VFS.Base {
|
||||
name = 'memory';
|
||||
|
||||
// Map of existing files, keyed by filename.
|
||||
mapNameToFile = new Map();
|
||||
|
||||
// Map of open files, keyed by id (sqlite3_file pointer).
|
||||
mapIdToFile = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const fileId of this.mapIdToFile.keys()) {
|
||||
this.xClose(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string?} name
|
||||
* @param {number} fileId
|
||||
* @param {number} flags
|
||||
* @param {DataView} pOutFlags
|
||||
* @returns {number}
|
||||
*/
|
||||
xOpen(name, fileId, flags, pOutFlags) {
|
||||
// Generate a random name if requested.
|
||||
name = name || Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36);
|
||||
|
||||
let file = this.mapNameToFile.get(name);
|
||||
if (!file) {
|
||||
if (flags & VFS.SQLITE_OPEN_CREATE) {
|
||||
// Create a new file object.
|
||||
file = {
|
||||
name,
|
||||
flags,
|
||||
size: 0,
|
||||
data: new ArrayBuffer(0)
|
||||
};
|
||||
this.mapNameToFile.set(name, file);
|
||||
} else {
|
||||
return VFS.SQLITE_CANTOPEN;
|
||||
}
|
||||
}
|
||||
|
||||
// Put the file in the opened files map.
|
||||
this.mapIdToFile.set(fileId, file);
|
||||
pOutFlags.setInt32(0, flags, true);
|
||||
return VFS.SQLITE_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @returns {number}
|
||||
*/
|
||||
xClose(fileId) {
|
||||
const file = this.mapIdToFile.get(fileId);
|
||||
if (file) {
|
||||
this.mapIdToFile.delete(fileId);
|
||||
if (file.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
|
||||
this.mapNameToFile.delete(file.name);
|
||||
}
|
||||
}
|
||||
return VFS.SQLITE_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @param {Uint8Array} pData
|
||||
* @param {number} iOffset
|
||||
* @returns {number}
|
||||
*/
|
||||
xRead(fileId, pData, iOffset) {
|
||||
const file = this.mapIdToFile.get(fileId);
|
||||
|
||||
// Clip the requested read to the file boundary.
|
||||
const bgn = Math.min(iOffset, file.size);
|
||||
const end = Math.min(iOffset + pData.byteLength, file.size);
|
||||
const nBytes = end - bgn;
|
||||
|
||||
if (nBytes) {
|
||||
pData.set(new Uint8Array(file.data, bgn, nBytes));
|
||||
}
|
||||
|
||||
if (nBytes < pData.byteLength) {
|
||||
// Zero unused area of read buffer.
|
||||
pData.fill(0, nBytes);
|
||||
return VFS.SQLITE_IOERR_SHORT_READ;
|
||||
}
|
||||
return VFS.SQLITE_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @param {Uint8Array} pData
|
||||
* @param {number} iOffset
|
||||
* @returns {number}
|
||||
*/
|
||||
xWrite(fileId, pData, iOffset) {
|
||||
const file = this.mapIdToFile.get(fileId);
|
||||
if (iOffset + pData.byteLength > file.data.byteLength) {
|
||||
// Resize the ArrayBuffer to hold more data.
|
||||
const newSize = Math.max(iOffset + pData.byteLength, 2 * file.data.byteLength);
|
||||
const data = new ArrayBuffer(newSize);
|
||||
new Uint8Array(data).set(new Uint8Array(file.data, 0, file.size));
|
||||
file.data = data;
|
||||
}
|
||||
|
||||
// Copy data.
|
||||
new Uint8Array(file.data, iOffset, pData.byteLength).set(pData);
|
||||
file.size = Math.max(file.size, iOffset + pData.byteLength);
|
||||
return VFS.SQLITE_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @param {number} iSize
|
||||
* @returns {number}
|
||||
*/
|
||||
xTruncate(fileId, iSize) {
|
||||
const file = this.mapIdToFile.get(fileId);
|
||||
|
||||
// For simplicity we don't make the ArrayBuffer smaller.
|
||||
file.size = Math.min(file.size, iSize);
|
||||
return VFS.SQLITE_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} fileId
|
||||
* @param {DataView} pSize64
|
||||
* @returns {number}
|
||||
*/
|
||||
xFileSize(fileId, pSize64) {
|
||||
const file = this.mapIdToFile.get(fileId);
|
||||
|
||||
pSize64.setBigInt64(0, BigInt(file.size), true);
|
||||
return VFS.SQLITE_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {number} syncDir
|
||||
* @returns {number}
|
||||
*/
|
||||
xDelete(name, syncDir) {
|
||||
this.mapNameToFile.delete(name);
|
||||
return VFS.SQLITE_OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {number} flags
|
||||
* @param {DataView} pResOut
|
||||
* @returns {number}
|
||||
*/
|
||||
xAccess(name, flags, pResOut) {
|
||||
const file = this.mapNameToFile.get(name);
|
||||
pResOut.setInt32(0, file ? 1 : 0, true);
|
||||
return VFS.SQLITE_OK;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user