feat: 支持 Proton Pass, Dashlane Pass 备份数据导入
This commit is contained in:
1 parent
5e874c920a
commit
d3a156bef8
14 files changed
+197
-53
No files matched your search
@@ -21,6 +21,7 @@
|
||||
"jspdf": "^4.2.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"libsodium-wrappers-sumo": "^0.8.2",
|
||||
"openpgp": "^6.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"scrypt-js": "^3.0.1",
|
||||
@@ -44,4 +45,4 @@
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,41 +377,35 @@
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-regular);
|
||||
padding: 3px 10px 4px 10px;
|
||||
border-radius: 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.category-tag:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
/* Final Choice: Option 1 - Subtle Glassmorphism (毛玻璃高亮) */
|
||||
/* 分类 Chips 选中状态对齐 .vault-card.is-selected */
|
||||
.category-tag.is-active {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
background-color: var(--vault-selected-bg) !important;
|
||||
border: 1px solid #409EFF !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
border-color: var(--el-color-primary-light-5) !important;
|
||||
}
|
||||
|
||||
/* 适配深色模式下的透明度感 */
|
||||
html.dark .category-tag.is-active {
|
||||
background-color: rgba(64, 158, 255, 0.15) !important;
|
||||
border-color: rgba(64, 158, 255, 0.4) !important;
|
||||
color: var(--el-color-primary-light-3) !important;
|
||||
}
|
||||
|
||||
.category-tag.is-active .tag-count {
|
||||
color: var(--el-color-primary) !important;
|
||||
opacity: 0.7;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
html.dark .category-tag.is-active .tag-count {
|
||||
color: var(--el-color-primary-light-3) !important;
|
||||
/* 适配深色模式下的透明度感 */
|
||||
html.dark .category-tag.is-active {
|
||||
border: none !important;
|
||||
background-color: rgba(64, 158, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
|
||||
@@ -182,7 +182,7 @@ export function useDataImport(emitFn) {
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedType === '2fauth_encrypted' || detectedType === 'aegis_encrypted' || detectedType === 'proton_encrypted' || detectedType === '2fas_encrypted' || detectedType === 'ente_encrypted' || detectedType === 'bitwarden_vault_encrypted') {
|
||||
if (detectedType === '2fauth_encrypted' || detectedType === 'aegis_encrypted' || detectedType === 'proton_auth_encrypted' || detectedType === 'proton_pass_pgp' || detectedType === '2fas_encrypted' || detectedType === 'ente_encrypted' || detectedType === 'bitwarden_vault_encrypted') {
|
||||
importPassword.value = ''
|
||||
isDialogHandled.value = false
|
||||
showDecryptDialog.value = true
|
||||
|
||||
@@ -5,7 +5,8 @@ import { tryParseJSON } from '@/shared/utils/encoding'
|
||||
import { gaMigrationStrategy } from '@/shared/utils/serializers/gauthStrategy'
|
||||
import { csvStrategy } from '@/shared/utils/serializers/csvStrategy'
|
||||
import { aegisStrategy } from '@/shared/utils/serializers/aegisStrategy'
|
||||
import protonStrategy from '@/shared/utils/serializers/protonStrategy'
|
||||
import { protonAuthStrategy } from '@/shared/utils/serializers/protonAuthStrategy'
|
||||
import { protonPassStrategy } from '@/shared/utils/serializers/protonPassStrategy'
|
||||
import { enteStrategy } from '@/shared/utils/serializers/enteStrategy'
|
||||
import { migrationError } from '@/shared/utils/errors/migrationError'
|
||||
import { unzipSync } from 'fflate'
|
||||
@@ -75,7 +76,9 @@ export const dataMigrationService = {
|
||||
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
|
||||
if (firstLine.includes('otpauth')) return 'bitwarden_auth_csv'
|
||||
if (firstLine.includes('totp') && firstLine.includes('vault') && firstLine.includes('createtime')) return 'proton_pass_csv'
|
||||
if (firstLine.includes('otpurl') && firstLine.includes('title') && firstLine.includes('username')) return 'dashlane_csv'
|
||||
return 'generic_csv'
|
||||
}
|
||||
|
||||
@@ -99,7 +102,7 @@ export const dataMigrationService = {
|
||||
if (json.schemaVersion && Array.isArray(json.services)) return '2fas'
|
||||
if (json.version === 1 && json.db && typeof json.db === 'object' && Array.isArray(json.db.entries)) return 'aegis' // Aegis unencrypted
|
||||
if (json.version === 1 && json.header && json.db && typeof json.db === 'string') return 'aegis_encrypted'
|
||||
if (json.version === 1 && typeof json.salt === 'string' && typeof json.content === 'string') return 'proton_encrypted'
|
||||
if (json.version === 1 && typeof json.salt === 'string' && typeof json.content === 'string') return 'proton_auth_encrypted'
|
||||
// Ente Auth 加密导出:同时包含 kdfParams 和 encryptedData 两个关键字段
|
||||
if (json.kdfParams && typeof json.encryptedData === 'string') return 'ente_encrypted'
|
||||
// Bitwarden password-protected export (Vault)
|
||||
@@ -107,6 +110,10 @@ export const dataMigrationService = {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof textContent === 'string' && textContent.includes('-----BEGIN PGP MESSAGE-----')) {
|
||||
return 'proton_pass_pgp'
|
||||
}
|
||||
|
||||
if (filename) {
|
||||
const ext = filename.toLowerCase()
|
||||
if (ext.endsWith('.2fas')) return '2fas'
|
||||
@@ -1091,8 +1098,17 @@ export const dataMigrationService = {
|
||||
type = 'raw'
|
||||
}
|
||||
|
||||
if (type === 'proton_encrypted') {
|
||||
rawVault = await protonStrategy.parse(content, password)
|
||||
if (type === 'proton_auth_encrypted') {
|
||||
if (!password) throw new migrationError('导入 Proton Authenticator 备份需要密码', 'MISSING_PASSWORD')
|
||||
rawVault = await protonAuthStrategy.parse(content, password)
|
||||
type = 'raw'
|
||||
content = JSON.stringify(rawVault)
|
||||
password = undefined
|
||||
}
|
||||
|
||||
if (type === 'proton_pass_pgp') {
|
||||
if (!password) throw new migrationError('导入 Proton Pass 备份需要密码', 'MISSING_PASSWORD')
|
||||
rawVault = await protonPassStrategy.parse(content, password)
|
||||
type = 'raw'
|
||||
content = JSON.stringify(rawVault)
|
||||
password = undefined
|
||||
@@ -1272,7 +1288,7 @@ export const dataMigrationService = {
|
||||
}))
|
||||
}
|
||||
}
|
||||
else if (type === 'bitwarden_vault_csv' || type === 'bitwarden_auth_csv' || type === '1password_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
|
||||
else if (type === 'bitwarden_vault_csv' || type === 'bitwarden_auth_csv' || type === '1password_csv' || type === 'proton_pass_csv' || type === 'dashlane_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
|
||||
|
||||
return rawVault.map(acc => {
|
||||
if (typeof acc.account === 'string' && acc.account.includes(':')) {
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
</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>
|
||||
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><icon1Password /></el-icon> 1Password (.1pux/.csv)</el-button>
|
||||
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconProtonPass /></el-icon> Proton Pass (.pgp/.csv)</el-button>
|
||||
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconDashlanePass /></el-icon> Dashlane (.csv)</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +101,7 @@
|
||||
|
||||
<!-- 详细引导 -->
|
||||
<el-divider border-style="dashed" content-position="center" class="mt-30">
|
||||
<span class="text-secondary font-12">{{ $t('migration.ga_ms_import_guide') }}</span>
|
||||
<span class="text-secondary font-12">{{ $t('migration.import_guide') }}</span>
|
||||
</el-divider>
|
||||
|
||||
<div class="guide-section">
|
||||
@@ -149,7 +151,8 @@
|
||||
<!-- 加密文件密码输入弹窗 -->
|
||||
<el-dialog v-model="showDecryptDialog" :title="$t('migration.decrypt_backup_title')" :width="layoutStore.isMobile ? '90%' : '400px'" @close="handleDecryptDialogClose" destroy-on-close>
|
||||
<el-alert v-if="currentImportType === 'aegis_encrypted'" :title="$t('migration.detect_aegis')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === 'proton_encrypted'" :title="$t('migration.detect_proton')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === 'proton_auth_encrypted'" :title="$t('migration.detect_proton_auth')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === 'proton_pass_pgp'" :title="$t('migration.detect_proton_pass')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === '2fas_encrypted'" :title="$t('migration.detect_2fas')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === 'ente_encrypted'" :title="$t('migration.detect_ente')" type="warning" :closable="false" class="mb-15" />
|
||||
<el-alert v-else-if="currentImportType === 'bitwarden_vault_encrypted'" :title="$t('migration.detect_bitwarden_vault')" type="warning" :closable="false" class="mb-15" />
|
||||
@@ -187,7 +190,9 @@ import iconAegis from '@/shared/components/icons/iconAegis.vue'
|
||||
import iconGoogleAuth from '@/shared/components/icons/iconGoogleAuth.vue'
|
||||
import iconBitwarden from '@/shared/components/icons/iconBitwarden.vue'
|
||||
import iconMicrosoftAuth from '@/shared/components/icons/iconMicrosoftAuth.vue'
|
||||
import iconDashlanePass from '@/shared/components/icons/iconDashlanePass.vue'
|
||||
import iconProtonAuth from '@/shared/components/icons/iconProtonAuth.vue'
|
||||
import iconProtonPass from '@/shared/components/icons/iconProtonPass.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'
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"update_available": "Update Available",
|
||||
"pwa_update_ready": "🎉 New Cloudflare Worker deployment is ready!",
|
||||
"refresh_now": "Refresh Now",
|
||||
"loading_failed": "Component loading failed, please check your network connection",
|
||||
"copy_success": "Copy success",
|
||||
"copy_fail": "Copy failed",
|
||||
"offline_mode": "Network disconnected, the application is currently in offline cache mode",
|
||||
@@ -371,16 +372,17 @@
|
||||
"center_desc": "Support restoring data from various 2FA apps or system backups.",
|
||||
"auto_identify_tip": "💡 Automatically recognize file types",
|
||||
"drag_drop_tip": "Drag backup files or migration QR screenshots here, or <em>click to upload</em>",
|
||||
"support_desc": "Supported Formats",
|
||||
"support_desc": "Supported List",
|
||||
"ga_auth_desc": "Export 'Transfer QR code' in the App and upload the screenshot (.png / .jpg)",
|
||||
"ms_auth_desc": "Microsoft Authenticator (Android Only)",
|
||||
"ms_auth_detail": "Export the following 3 files from Android and upload them together",
|
||||
"ga_ms_import_guide": "Google / Microsoft Import Guide",
|
||||
"import_guide": "Import Guide",
|
||||
"batch_import_processing": "📦 Batch Import Processing",
|
||||
"batch_progress": "{processed} / {total} tasks completed",
|
||||
"decrypt_backup_title": "🔓 Decrypt Backup File",
|
||||
"detect_aegis": "Aegis encrypted backup detected",
|
||||
"detect_proton": "Proton Auth encrypted backup detected",
|
||||
"detect_proton_auth": "Proton Authenticator encrypted backup detected",
|
||||
"detect_proton_pass": "Proton Pass encrypted backup detected (.pgp)",
|
||||
"detect_2fas": "2FAS Auth encrypted backup detected",
|
||||
"detect_ente": "Ente Auth encrypted backup detected",
|
||||
"detect_bitwarden_vault": "Bitwarden Vault encrypted backup detected",
|
||||
@@ -390,7 +392,6 @@
|
||||
"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",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"update_available": "更新可用",
|
||||
"pwa_update_ready": "🎉 Cloudflare Worker 新部署已就绪!",
|
||||
"refresh_now": "立即刷新",
|
||||
"loading_failed": "组件加载失败,请检查网络连接",
|
||||
"copy_success": "复制成功",
|
||||
"copy_fail": "复制失败",
|
||||
"offline_mode": "网络已断开,当前应用正处于离线缓存模式",
|
||||
@@ -371,16 +372,17 @@
|
||||
"center_desc": "支持从各类 2FA 软件或本系统的备份文件中恢复数据。",
|
||||
"auto_identify_tip": "💡 自动识别文件类型导入",
|
||||
"drag_drop_tip": "将备份文件或迁移二维码截图拖到此处,或 <em>点击上传</em>",
|
||||
"support_desc": "支持情况说明",
|
||||
"support_desc": "支持列表",
|
||||
"ga_auth_desc": "请在 App 内导出\"迁移二维码\",将截图直接上传 (.png / .jpg)",
|
||||
"ms_auth_desc": "Microsoft Authenticator (仅支持 Android)",
|
||||
"ms_auth_detail": "Android 手机导出以下3个文件后,同时选中上传即可导入",
|
||||
"ga_ms_import_guide": "Google / Microsoft Auth 导入指南",
|
||||
"import_guide": "导入指南",
|
||||
"batch_import_processing": "📦 批量导入处理中",
|
||||
"batch_progress": "{processed} / {total} 任务已完成",
|
||||
"decrypt_backup_title": "🔓 解密备份文件",
|
||||
"detect_aegis": "检测到 Aegis 加密备份",
|
||||
"detect_proton": "检测到 Proton Auth 加密备份",
|
||||
"detect_proton_auth": "检测到 Proton Authenticator 加密备份",
|
||||
"detect_proton_pass": "检测到 Proton Pass 加密备份 (.pgp)",
|
||||
"detect_2fas": "检测到 2FAS Auth 加密备份",
|
||||
"detect_ente": "检测到 Ente Auth 加密备份",
|
||||
"detect_bitwarden_vault": "检测到 Bitwarden Vault 加密备份",
|
||||
@@ -390,7 +392,6 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg fill="currentColor" width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.89 7.719c0-.149-.149-.298-.357-.357L18.03 6.44c-.416-.179-.893.03-.893.327V17.25c0 .149.15.327.298.387l2.561.922c.388.149.894-.06.894-.387zm-4.676-3.663c0-.15-.149-.298-.358-.358l-2.501-.922c-.417-.178-.894.03-.894.328v6.372c0 .15.149.328.298.388l2.561.922c.388.149.894-.06.894-.387zm0 11.466c0-.149-.149-.297-.358-.357l-2.501-.922c-.417-.179-.894.03-.894.328v6.372c0 .149.149.327.298.387l2.561.922c.388.15.894-.06.894-.387zm-4.676 1.281c0-.149-.149-.298-.358-.357l-2.501-.923c-.42-.178-.894.03-.894.328v6.79c0 .15.149.328.298.388l2.561.922c.387.15.894-.06.894-.387zm0-15.458c0-.149-.149-.297-.358-.357L8.68.066c-.42-.179-.894.03-.894.328v6.79c0 .15.149.328.298.387l2.561.922c.388.15.894-.06.894-.387zm-4.675.269c0-.15-.15-.298-.358-.358L4.004.334c-.417-.179-.894.03-.894.328v21.74c0 .15.149.328.298.387l2.561.923c.387.148.894-.06.894-.388z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconDashlanePass' }
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg width="1em" height="1em" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3px" x="8.379" y="8.379" width="31.242" height="31.242" rx="4" ry="4" transform="translate(24 -9.9411) rotate(45)"/>
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3px" d="m10.1332,11.3867h18.2265c2.2077,0,4,1.7923,4,4v18.2265c0,1.6557-1.3443,3-3,3H10.1332c-1.6557,0-3-1.3443-3-3V14.3867c0-1.6557,1.3443-3,3-3Z" transform="translate(16.7387 54.9334) rotate(-135)"/>
|
||||
</svg>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'iconProtonPass' }
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineAsyncComponent, h } from 'vue'
|
||||
import { defineAsyncComponent, h, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElEmpty, ElButton } from 'element-plus'
|
||||
import { i18n } from '@/locales'
|
||||
|
||||
/**
|
||||
* 增强的异步组件工厂函数
|
||||
@@ -10,14 +11,37 @@ export function createAsyncComponent(loader) {
|
||||
loader,
|
||||
// 加载中占位
|
||||
loadingComponent: {
|
||||
render() {
|
||||
return h('div', {
|
||||
setup() {
|
||||
const dots = ref('...')
|
||||
let timer = null
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
if (dots.value.length >= 10) {
|
||||
dots.value = '...'
|
||||
} else {
|
||||
dots.value += '.'
|
||||
}
|
||||
}, 400)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
return () => h('div', {
|
||||
style: 'display: flex; justify-content: center; align-items: center; min-height: 400px; width: 100%;'
|
||||
}, [
|
||||
h('div', {
|
||||
class: 'is-loading',
|
||||
style: 'font-size: 24px;'
|
||||
}, 'Loading...')
|
||||
style: `
|
||||
font-size: 24px;
|
||||
font-family: monospace;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
`
|
||||
}, `Loading${dots.value}`)
|
||||
])
|
||||
}
|
||||
},
|
||||
@@ -25,14 +49,15 @@ export function createAsyncComponent(loader) {
|
||||
errorComponent: {
|
||||
props: ['error'],
|
||||
render() {
|
||||
const { t } = i18n.global
|
||||
return h('div', {
|
||||
style: 'padding: 40px; text-align: center;'
|
||||
}, [
|
||||
h(ElEmpty, { description: '组件加载失败,请检查网络连接' }, {
|
||||
h(ElEmpty, { description: t('common.loading_failed') }, {
|
||||
default: () => h(ElButton, {
|
||||
type: 'primary',
|
||||
onClick: () => window.location.reload()
|
||||
}, '刷新页面')
|
||||
}, t('common.refresh_now'))
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
@@ -66,9 +66,11 @@ export const csvStrategy = {
|
||||
const isBitwardenVault = headers.includes('login_totp')
|
||||
const isBitwardenAuth = headers.includes('otpauth') && !headers.includes('title')
|
||||
const is1Password = headers.includes('otpauth') && headers.includes('title')
|
||||
const isProtonPass = headers.includes('totp') && headers.includes('vault') && headers.includes('createtime')
|
||||
const isDashlane = headers.includes('otpurl') && headers.includes('title') && headers.includes('username')
|
||||
const isGeneric = headers.includes('issuer') || headers.includes('secret') || headers.includes('name')
|
||||
|
||||
if (!isBitwardenVault && !isBitwardenAuth && !is1Password && !isGeneric) return []
|
||||
if (!isBitwardenVault && !isBitwardenAuth && !is1Password && !isProtonPass && !isDashlane && !isGeneric) return []
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const row = this._splitCsvLine(lines[i])
|
||||
@@ -114,6 +116,28 @@ export const csvStrategy = {
|
||||
rawVault.push(accData)
|
||||
}
|
||||
}
|
||||
} else if (isProtonPass) {
|
||||
const totpStr = (rowData['totp'] || '').trim()
|
||||
if (totpStr && totpStr.startsWith('otpauth://')) {
|
||||
const accData = parseOtpUri(totpStr)
|
||||
if (accData) {
|
||||
accData.service = rowData['name'] || accData.service
|
||||
accData.account = rowData['username'] || accData.account
|
||||
accData.category = rowData['vault'] || ''
|
||||
rawVault.push(accData)
|
||||
}
|
||||
}
|
||||
} else if (isDashlane) {
|
||||
const totpStr = (rowData['otpurl'] || '').trim()
|
||||
if (totpStr && totpStr.startsWith('otpauth://')) {
|
||||
const accData = parseOtpUri(totpStr)
|
||||
if (accData) {
|
||||
accData.service = rowData['title'] || accData.service
|
||||
accData.account = rowData['username'] || accData.account
|
||||
accData.category = rowData['category'] || ''
|
||||
rawVault.push(accData)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const secret = rowData['secret'] || ''
|
||||
if (secret) {
|
||||
|
||||
+9
-8
@@ -1,8 +1,12 @@
|
||||
import argon2 from 'argon2-browser/dist/argon2-bundled.min.js';
|
||||
import { parseOtpUri } from '@/shared/utils/totp';
|
||||
|
||||
export default {
|
||||
name: 'Proton Pass (.json)',
|
||||
/**
|
||||
* Proton Authenticator (.json) Strategy
|
||||
* Uses Argon2id + AES-GCM
|
||||
*/
|
||||
export const protonAuthStrategy = {
|
||||
name: 'Proton Authenticator (.json)',
|
||||
fileType: 'application/json, text/plain',
|
||||
|
||||
async parse(fileContent, password) {
|
||||
@@ -52,8 +56,6 @@ export default {
|
||||
distPath: '/'
|
||||
});
|
||||
|
||||
// 2. Decrypt via Web Crypto API
|
||||
// argon2-browser returns Uint8Array in argon2Result.hash
|
||||
const keyMaterial = argon2Result.hash;
|
||||
|
||||
const cryptoKey = await window.crypto.subtle.importKey(
|
||||
@@ -64,7 +66,7 @@ export default {
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// AAD defined in Rust
|
||||
// AAD defined in Rust source of Proton Auth
|
||||
const aad = new TextEncoder().encode('proton.authenticator.export.v1');
|
||||
|
||||
const decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||
@@ -81,7 +83,7 @@ export default {
|
||||
const decryptedString = new TextDecoder().decode(decryptedBuffer);
|
||||
const decryptedJson = JSON.parse(decryptedString);
|
||||
|
||||
// 3. Parse items
|
||||
// Parse items
|
||||
const entries = decryptedJson.entries || [];
|
||||
const results = [];
|
||||
|
||||
@@ -97,8 +99,7 @@ export default {
|
||||
return results;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Proton decryption failed:', err);
|
||||
// Must throw this precise message so UI prompts for password again if wrong
|
||||
console.error('Proton Authenticator decryption failed:', err);
|
||||
throw new Error('INVALID_FORMAT_OR_PASSWORD');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { parseOtpUri } from '@/shared/utils/totp';
|
||||
import * as openpgp from 'openpgp';
|
||||
|
||||
/**
|
||||
* Proton Pass (.pgp) Strategy
|
||||
* Uses OpenPGP (Armored Message)
|
||||
*/
|
||||
export const protonPassStrategy = {
|
||||
name: 'Proton Pass (.pgp)',
|
||||
fileType: 'text/plain',
|
||||
|
||||
async parse(armoredContent, password) {
|
||||
try {
|
||||
const message = await openpgp.readMessage({
|
||||
armoredMessage: armoredContent
|
||||
});
|
||||
|
||||
const { data: decrypted } = await openpgp.decrypt({
|
||||
message,
|
||||
passwords: [password],
|
||||
format: 'utf8'
|
||||
});
|
||||
|
||||
const decryptedJson = JSON.parse(decrypted);
|
||||
const results = [];
|
||||
|
||||
const vaults = decryptedJson.vaults || {};
|
||||
|
||||
for (const vaultId in vaults) {
|
||||
const vault = vaults[vaultId];
|
||||
const items = vault.items || [];
|
||||
for (const item of items) {
|
||||
const data = item.data || {};
|
||||
const content = data.content || {};
|
||||
const metadata = data.metadata || {};
|
||||
|
||||
if (content.totpUri) {
|
||||
const parsed = parseOtpUri(content.totpUri);
|
||||
if (parsed) {
|
||||
// Use metadata name if available for service
|
||||
if (metadata.name) parsed.service = metadata.name;
|
||||
if (content.itemUsername) parsed.account = content.itemUsername;
|
||||
results.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error('Proton Pass PGP decryption failed:', err);
|
||||
throw new Error('INVALID_FORMAT_OR_PASSWORD');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -84,7 +84,8 @@ export default defineConfig({
|
||||
'**/assets/pdf-utils*.js',
|
||||
'**/assets/compression-utils*.js',
|
||||
'**/assets/dataImport*.js',
|
||||
'**/assets/dataMigrationService*.js'
|
||||
'**/assets/dataMigrationService*.js',
|
||||
'**/assets/openpgp*.js'
|
||||
],
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
@@ -117,7 +118,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
// 为被 globIgnores 忽略的大体积工具 JS chunk 配置动态缓存
|
||||
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|pdf-utils|compression-utils|dataImport|dataMigrationService).*\.js$/i,
|
||||
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|pdf-utils|compression-utils|dataImport|dataMigrationService|openpgp).*\.js$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'lazy-tools-js-cache',
|
||||
@@ -190,6 +191,9 @@ export default defineConfig({
|
||||
// 二维码处理相关 (体积较大且仅特定页面使用)
|
||||
if (id.includes('/node_modules/qrcode/') || id.includes('/node_modules/jsqr/')) return 'qr-utils'
|
||||
|
||||
// 加密相关:OpenPGP (体积大且仅特定导入场景使用)
|
||||
if (id.includes('/node_modules/openpgp/')) return 'openpgp'
|
||||
|
||||
// 大型 UI 支撑库
|
||||
if (id.includes('/node_modules/@tanstack/')) return 'tanstack-query'
|
||||
|
||||
|
||||
Reference in new issue
Block a user