feat: 支持 1Password, Lastpass Auth 备份数据导入

This commit is contained in:
nap0o committed 2026-03-16 00:21:33 +08:00
1 parent cfa32d354a
commit d51d558500
13 files changed
+146 -21

No files matched your search

+1
View File
@@ -15,6 +15,7 @@
"argon2-browser": "^1.18.0",
"bcryptjs": "^3.0.3",
"element-plus": "^2.9.5",
"fflate": "^0.8.2",
"hash-wasm": "^4.12.0",
"jsqr": "^1.4.0",
"libsodium-wrappers-sumo": "^0.8.2",
@@ -63,7 +63,8 @@
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important;
}
.vault-list-wrapper .card-header {
.vault-list-wrapper .card-header,
.drag-floating-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
@@ -96,7 +97,8 @@
}
/* 彻底屏蔽可能被注入的链接样式 */
.service-name a, .vault-name a {
.service-name a,
.vault-name a {
color: inherit !important;
text-decoration: none !important;
pointer-events: none !important;
@@ -104,7 +106,9 @@
/* 在移动端彻底禁用点击,防止触发自动识别的链接 */
@media (max-width: 767px) {
.service-name, .vault-name {
.service-name,
.vault-name {
pointer-events: none !important;
}
}
@@ -334,9 +338,9 @@
z-index: 9999;
opacity: 0.95;
transform: scale(1.05);
/* 移除旋转 */
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.3) !important;
transition: transform 0.1s ease-out;
border-radius: 12px;
}
/* 消息提示磨砂风格 (全局样式注入建议) */
@@ -125,11 +125,11 @@ export function useDataExport() {
mimeType = 'text/plain'
filename = `2fauth-worker-export-otpauth-${date}.txt`
break
case 'csv':
case 'generic_csv':
mimeType = 'text/csv'
filename = `2fauth-worker-export-csv-${date}.csv`
break
case 'bitwarden_auth':
case 'bitwarden_auth_json':
mimeType = 'application/json'
filename = `2fauth-worker-export-bitwarden-auth-${date}.json`
break
@@ -174,7 +174,7 @@ export function useDataImport(emitFn) {
}
let contentForParse = buffer
if (detectedType !== 'phonefactor') {
if (detectedType !== 'phonefactor' && detectedType !== '1password_pux') {
try {
contentForParse = new TextDecoder().decode(buffer)
} catch {
@@ -8,6 +8,7 @@ import { aegisStrategy } from '@/shared/utils/serializers/aegisStrategy'
import protonStrategy from '@/shared/utils/serializers/protonStrategy'
import { enteStrategy } from '@/shared/utils/serializers/enteStrategy'
import { migrationError } from '@/shared/utils/errors/migrationError'
import { unzipSync } from 'fflate'
import jsQR from 'jsqr'
@@ -33,7 +34,7 @@ export const dataMigrationService = {
* 智能识别导入内容或文件的类型
* @param {string|ArrayBuffer|Uint8Array} content - 文件文本内容或二进制数据
* @param {string} filename - 文件名
* @returns {'bitwarden_vault_csv'|'bitwarden_auth_csv'|'generic_csv'|'generic_text'|'bitwarden_vault_json'|'bitwarden_auth_json'|'bitwarden_vault_encrypted'|'2fauth_encrypted'|'2fauth_json'|'2fas'|'2fas_encrypted'|'aegis'|'aegis_encrypted'|'phonefactor'|'unknown'} 返回类型标识
* @returns {'bitwarden_vault_csv'|'bitwarden_auth_csv'|'generic_csv'|'generic_text'|'bitwarden_vault_json'|'bitwarden_auth_json'|'bitwarden_vault_encrypted'|'2fauth_encrypted'|'2fauth_json'|'2fas'|'2fas_encrypted'|'aegis'|'aegis_encrypted'|'phonefactor'|'1password_pux'|'lastpass_auth_json'|'unknown'} 返回类型标识
*/
detectFileType(content, filename) {
// 如果是二进制数据,尝试用更宽容的方式判断 PhoneFactor
@@ -73,6 +74,7 @@ export const dataMigrationService = {
if (filename && filename.toLowerCase().endsWith('.csv')) {
const firstLine = typeof textContent === 'string' ? textContent.split('\n')[0].toLowerCase() : ''
if (firstLine.includes('login_totp')) return 'bitwarden_vault_csv'
if (firstLine.includes('title') && firstLine.includes('otpauth')) return '1password_csv'
if (firstLine.includes('otpauth')) return 'bitwarden_auth_csv' // Bitwarden Auth standalone typically exports simpler CSV
return 'generic_csv'
}
@@ -89,6 +91,8 @@ export const dataMigrationService = {
// 如果只包含 items 且是明文,归类为 bitwarden_auth_json (之前测试过的独立 App 路径)
if (Array.isArray(json.items) && (json.encrypted === false || !('encrypted' in json))) return 'bitwarden_auth_json'
if (json.encrypted === true && json.app === '2fauth') return '2fauth_encrypted'
// LastPass Authenticator (version 1 + accounts array with issuerName)
if (json.version === 1 && Array.isArray(json.accounts) && (json.accounts.length === 0 || json.accounts[0].issuerName)) return 'lastpass_auth_json'
if (json.app === '2fauth' || Array.isArray(json.accounts) || Array.isArray(json.vault) || Array.isArray(json.secrets)) return '2fauth_json'
// 2FAS encrypted: schemaVersion + servicesEncrypted(colon-separated salt:iv:cipher)
if (json.schemaVersion && json.servicesEncrypted && typeof json.servicesEncrypted === 'string') return '2fas_encrypted'
@@ -107,6 +111,7 @@ export const dataMigrationService = {
const ext = filename.toLowerCase()
if (ext.endsWith('.2fas')) return '2fas'
if (ext.endsWith('.txt')) return 'generic_text'
if (ext.endsWith('.1pux')) return '1password_pux'
}
return 'unknown'
@@ -237,7 +242,7 @@ export const dataMigrationService = {
}).join('\n')
}
if (type === 'csv') {
if (type === 'generic_csv') {
if (variant === 'bitwarden_auth') {
let csv = 'name,secret,totp,favorite,folder\n'
vault.forEach(acc => {
@@ -259,7 +264,7 @@ export const dataMigrationService = {
return csv
}
if (type === 'bitwarden_auth') {
if (type === 'bitwarden_auth_json') {
const items = vault.map(acc => {
const label = encodeURIComponent(acc.account ? `${acc.service}:${acc.account}` : acc.service)
const issuer = encodeURIComponent(acc.service)
@@ -1062,6 +1067,11 @@ export const dataMigrationService = {
return await this.parsePhoneFactor(content)
}
// 处理 1Password PUX (ZIP)
if (type === '1password_pux') {
return await this.parse1Pux(content)
}
// 统一处理将可能传入的 ArrayBuffer 转回 string,以便后续非数据库格式的解析
let textContent = content
if (content instanceof ArrayBuffer || content instanceof Uint8Array) {
@@ -1075,7 +1085,7 @@ export const dataMigrationService = {
// 对于非 phonefactor 的格式,后续逻辑全部使用 textContent 替代 content
content = textContent;
if (type === 'bitwarden_auth_csv' || type === 'generic_csv') {
if (type === 'bitwarden_auth_csv' || type === '1password_csv' || type === 'generic_csv') {
rawVault = csvStrategy.parseCsv(content)
content = JSON.stringify(rawVault)
type = 'raw'
@@ -1249,7 +1259,20 @@ export const dataMigrationService = {
if (acc) rawVault.push(acc)
})
}
else if (type === 'bitwarden_vault_csv' || type === 'bitwarden_auth_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
else if (type === 'lastpass_auth_json') {
const json = typeof content === 'string' ? JSON.parse(content) : content
if (Array.isArray(json.accounts)) {
rawVault = json.accounts.map(s => ({
service: s.issuerName || s.originalIssuerName || 'Unknown',
account: s.userName || s.originalUserName || '',
secret: s.secret || '',
algorithm: s.algorithm || 'SHA1',
digits: s.digits || 6,
period: s.timeStep || 30,
}))
}
}
else if (type === 'bitwarden_vault_csv' || type === 'bitwarden_auth_csv' || type === '1password_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
return rawVault.map(acc => {
if (typeof acc.account === 'string' && acc.account.includes(':')) {
@@ -1308,6 +1331,64 @@ export const dataMigrationService = {
})
},
/**
* 3.2 解析 1Password .1pux 格式 (ZIP 归档)
* @param {ArrayBuffer|Uint8Array} content
* @returns {Promise<Object[]>}
*/
async parse1Pux(content) {
try {
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content)
const unzipped = unzipSync(bytes)
const dataFileContent = unzipped['export.data']
if (!dataFileContent) throw new Error('未能在 .1pux 文件中找到 export.data')
const text = new TextDecoder().decode(dataFileContent)
const data = JSON.parse(text)
const rawVault = []
const accounts = data.accounts || []
accounts.forEach(account => {
const vaults = account.vaults || []
vaults.forEach(vault => {
const items = vault.items || []
items.forEach(item => {
const title = item.overview?.title || 'Unknown'
const subtitle = item.overview?.subtitle || ''
// 1PUX 的 TOTP 字段路径通常在 sections[].fields 或是 loginFields 中
const checkFields = (fields) => {
if (!Array.isArray(fields)) return
fields.forEach(field => {
if (field.value && typeof field.value === 'object' && field.value.totp) {
rawVault.push({
service: title,
account: subtitle,
secret: field.value.totp,
algorithm: 'SHA1',
digits: 6,
period: 30
})
}
})
}
checkFields(item.details?.loginFields)
if (Array.isArray(item.details?.sections)) {
item.details.sections.forEach(section => checkFields(section.fields))
}
})
})
})
return rawVault
} catch (e) {
throw new migrationError('解析 1Password 备份失败: ' + (e.message || String(e)), 'ONEPASSWORD_PARSE_FAILED', e)
}
},
/**
* 4. 批量保存到后端
* @param {Object[]} vault
@@ -40,7 +40,7 @@
<el-button plain @click="openGaDialogDirectly" class="migration-button-with-icon">
<el-icon><iconGoogleAuth /></el-icon> {{ $t('migration.migrate_ga') }}
</el-button>
<el-button plain @click="openWarningDialog('bitwarden_auth')" class="migration-button-with-icon">
<el-button plain @click="openWarningDialog('bitwarden_auth_json')" class="migration-button-with-icon">
<el-icon><iconBitwarden /></el-icon> Bitwarden Auth (.json)
</el-button>
</div>
@@ -59,7 +59,7 @@
<el-button plain @click="openWarningDialog('generic_text')" class="migration-button-with-icon">
<el-icon><Tickets /></el-icon> {{ $t('migration.otpauth_txt') }}
</el-button>
<el-button plain @click="openWarningDialog('csv', 'generic')" class="migration-button-with-icon">
<el-button plain @click="openWarningDialog('generic_csv', 'generic')" class="migration-button-with-icon">
<el-icon><Grid /></el-icon> {{ $t('migration.spreadsheet_csv') }}
</el-button>
<el-button plain @click="openWarningDialog('2fauth_html')" class="migration-button-with-icon">
@@ -61,6 +61,7 @@
</h4>
<div class="ecosystem-grid">
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconBitwarden /></el-icon> Bitwarden Vault (.json/.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><icon1Password /></el-icon> {{ $t('migration.onepassword_format') }}</el-button>
</div>
</div>
@@ -78,6 +79,7 @@
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconEnte /></el-icon> Ente Auth(.txt)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconGoogleAuth /></el-icon> Google Auth (.png/.jpg)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconMicrosoftAuth /></el-icon> Microsoft Auth (PhoneFactor)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconLastPassAuth /></el-icon> LastPass Auth (.json)</el-button>
</div>
</div>
@@ -187,6 +189,8 @@ import iconBitwarden from '@/shared/components/icons/iconBitwarden.vue'
import iconMicrosoftAuth from '@/shared/components/icons/iconMicrosoftAuth.vue'
import iconProtonAuth from '@/shared/components/icons/iconProtonAuth.vue'
import iconEnte from '@/shared/components/icons/iconEnte.vue'
import icon1Password from '@/shared/components/icons/icon1Password.vue'
import iconLastPassAuth from '@/shared/components/icons/iconLastPassAuth.vue'
const emit = defineEmits(['success'])
+2
View File
@@ -390,9 +390,11 @@
"confirm_decrypt_import": "Confirm Decrypt & Import",
"system_backup_format": "System Backup",
"password_manager_format": "Password Manager (Vault)",
"onepassword_format": "1Password (.1pux/.csv)",
"encrypted_backup_json": "Encrypted Backup (.json)",
"plaintext_backup_json": "Plaintext Backup (.json)",
"mobile_app_format": "Mobile 2FA Apps",
"lastpass_auth": "LastPass Authenticator (.json)",
"generic_json": "Generic Format (.json)",
"fetching_accounts": "Fetching account data...",
"no_accounts_to_export": "No accounts to export",
+2
View File
@@ -390,9 +390,11 @@
"confirm_decrypt_import": "确认解密并导入",
"system_backup_format": "本系统备份",
"password_manager_format": "密码管理器 (Vault)",
"onepassword_format": "1Password (.1pux/.csv)",
"encrypted_backup_json": "加密备份 (.json)",
"plaintext_backup_json": "明文备份 (.json)",
"mobile_app_format": "移动端 2FA App",
"lastpass_auth": "LastPass Authenticator (.json)",
"generic_json": "通用格式 (.json)",
"fetching_accounts": "正在获取账号数据...",
"no_accounts_to_export": "没有可导出的账号",
@@ -0,0 +1,8 @@
<template>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24">
<path d="M12 0.007C5.373 0.007 0 5.376 0 11.999c0 6.624 5.373 11.994 12 11.994S24 18.623 24 12C24 5.376 18.627 0.007 12 0.007Zm-0.895 4.857h1.788c0.484 0 0.729 0.002 0.914 0.096a0.86 0.86 0 0 1 0.377 0.377c0.094 0.185 0.095 0.428 0.095 0.912v6.016c0 0.12 0 0.182 -0.015 0.238a0.427 0.427 0 0 1 -0.067 0.137 0.923 0.923 0 0 1 -0.174 0.162l-0.695 0.564c-0.113 0.092 -0.17 0.138 -0.191 0.194a0.216 0.216 0 0 0 0 0.15c0.02 0.055 0.078 0.101 0.191 0.193l0.695 0.565c0.094 0.076 0.14 0.115 0.174 0.162 0.03 0.042 0.053 0.087 0.067 0.137a0.936 0.936 0 0 1 0.015 0.238v2.746c0 0.484 -0.001 0.727 -0.095 0.912a0.86 0.86 0 0 1 -0.377 0.377c-0.185 0.094 -0.43 0.096 -0.914 0.096h-1.788c-0.484 0 -0.726 -0.002 -0.912 -0.096a0.86 0.86 0 0 1 -0.377 -0.377c-0.094 -0.185 -0.095 -0.428 -0.095 -0.912v-6.016c0 -0.12 0 -0.182 0.015 -0.238a0.437 0.437 0 0 1 0.067 -0.139c0.034 -0.047 0.08 -0.083 0.174 -0.16l0.695 -0.564c0.113 -0.092 0.17 -0.138 0.191 -0.194a0.216 0.216 0 0 0 0 -0.15c-0.02 -0.055 -0.078 -0.101 -0.191 -0.193l-0.695 -0.565a0.92 0.92 0 0 1 -0.174 -0.162 0.437 0.437 0 0 1 -0.067 -0.139 0.92 0.92 0 0 1 -0.015 -0.236V6.25c0 -0.484 0.001 -0.727 0.095 -0.912a0.86 0.86 0 0 1 0.377 -0.377c0.186 -0.094 0.428 -0.096 0.912 -0.096z" fill="currentColor" stroke-width="1"></path>
</svg>
</template>
<script>
export default { name: 'icon1Password' }
</script>
@@ -0,0 +1,12 @@
<template>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24" fill="none">
<path d="M21 6C20.4477 6 20 6.44771 20 7V17C20 17.5523 20.4477 18 21 18C21.5523 18 22 17.5523 22 17V7C22 6.44772 21.5523 6 21 6Z" fill="currentColor" />
<path d="M4 14C5.10457 14 6 13.1046 6 12C6 10.8954 5.10457 10 4 10C2.89543 10 2 10.8954 2 12C2 13.1046 2.89543 14 4 14Z" fill="currentColor"
/>
<path d="M12 12C12 13.1046 11.1046 14 10 14C8.89543 14 8 13.1046 8 12C8 10.8954 8.89543 10 10 10C11.1046 10 12 10.8954 12 12Z" fill="currentColor" />
<path d="M16 14C17.1046 14 18 13.1046 18 12C18 10.8954 17.1046 10 16 10C14.8954 10 14 10.8954 14 12C14 13.1046 14.8954 14 16 14Z" fill="currentColor" />
</svg>
</template>
<script>
export default { name: 'iconLastPassAuth' }
</script>
@@ -1,8 +1,8 @@
<template>
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" fill="none" width="1em" height="1em">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.36" d="M26.88 17.36h-7.84c-4.942 0 -9.072 4.046 -8.204 8.918C13.3 40.166 21.266 47.6 26.88 47.6m0 -30.24h7.84c4.942 0 9.072 4.046 8.204 8.918C40.46 40.166 32.494 47.6 26.88 47.6"/>
<path fill="currentColor" fill-rule="evenodd" d="M19.04 15.68a7.84 7.84 0 1 1 15.498 1.68h3.416a11.2 11.2 0 1 0 -22.148 0h3.416a8.4 8.4 0 0 1 -0.182 -1.68m-0.098 27.706a8.12 8.12 0 0 1 15.666 -0.798l2.52 -2.688a11.48 11.48 0 0 0 -20.468 -0.042l1.68 2.702z" clip-rule="evenodd"/>
<path stroke="currentColor" stroke-width="3.36" d="M30.8 26.32a3.92 3.92 0 0 1 -3.92 3.92 3.92 3.92 0 0 1 -3.92 -3.92 3.92 3.92 0 0 1 7.84 0z"/>
<svg width="1em" height="1em" viewBox="16 16 156 156" xmlns="http://www.w3.org/2000/svg" fill="none">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="12" d="M96 62H68c-17.673 0-32.389 14.46-29.302 31.862C47.497 143.453 75.94 170 96 170m0-108h28c17.673 0 32.389 14.46 29.302 31.862C144.503 143.453 116.06 170 96 170"/>
<path fill="currentColor" fill-rule="evenodd" d="M68 56c0-15.464 12.536-28 28-28s28 12.536 28 28c0 2.06-.222 4.067-.644 6h12.197c.294-1.957.447-3.96.447-6 0-22.091-17.909-40-40-40S56 33.909 56 56c0 2.04.153 4.043.447 6h12.197A28.105 28.105 0 0 1 68 56Zm-.367 98.946C70.417 141.836 82.06 132 96 132c12.903 0 23.838 8.427 27.601 20.077l8.982-9.608C125.816 129.136 111.975 120 96 120c-15.922 0-29.725 9.076-36.516 22.338L65.5 152l2.133 2.946Z" clip-rule="evenodd"/>
<circle cx="96" cy="94" r="14" stroke="currentColor" stroke-width="12"/>
</svg>
</template>
<script>
@@ -64,17 +64,28 @@ export const csvStrategy = {
const rawVault = []
const isBitwardenVault = headers.includes('login_totp')
const isBitwardenAuth = headers.includes('otpauth') // Bitwarden Auth standalone usually has simple column names
const isBitwardenAuth = headers.includes('otpauth') && !headers.includes('title')
const is1Password = headers.includes('otpauth') && headers.includes('title')
const isGeneric = headers.includes('issuer') || headers.includes('secret') || headers.includes('name')
if (!isBitwardenVault && !isBitwardenAuth && !isGeneric) return []
if (!isBitwardenVault && !isBitwardenAuth && !is1Password && !isGeneric) return []
for (let i = 1; i < lines.length; i++) {
const row = this._splitCsvLine(lines[i])
const rowData = {}
headers.forEach((h, index) => { rowData[h] = row[index] || '' })
if (isBitwardenVault || isBitwardenAuth) {
if (is1Password) {
const totpStr = (rowData['otpauth'] || '').trim()
if (totpStr && totpStr.startsWith('otpauth://')) {
const accData = parseOtpUri(totpStr)
if (accData) {
accData.service = rowData['title'] || accData.service
accData.account = rowData['username'] || accData.account
rawVault.push(accData)
}
}
} else if (isBitwardenVault || isBitwardenAuth) {
const totpStr = (rowData['login_totp'] || rowData['otpauth'] || rowData['totp'] || '').trim()
if (totpStr) {
let accData = null