From d51d55850096d4a91ee75a78d1d33d620e1828fc Mon Sep 17 00:00:00 2001 From: nap0o Date: Mon, 16 Mar 2026 00:21:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=201Password,=20Lastp?= =?UTF-8?q?ass=20Auth=20=E5=A4=87=E4=BB=BD=E6=95=B0=E6=8D=AE=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 1 + .../app/styles/modules/components/vault.css | 12 ++- .../migration/composables/useDataExport.js | 4 +- .../migration/composables/useDataImport.js | 2 +- .../migration/service/dataMigrationService.js | 91 ++++++++++++++++++- .../features/migration/views/dataExport.vue | 4 +- .../features/migration/views/dataImport.vue | 4 + frontend/src/locales/en-US.json | 2 + frontend/src/locales/zh-CN.json | 2 + .../shared/components/icons/icon1Password.vue | 8 ++ .../components/icons/iconLastPassAuth.vue | 12 +++ .../components/icons/iconMicrosoftAuth.vue | 8 +- .../shared/utils/serializers/csvStrategy.js | 17 +++- 13 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 frontend/src/shared/components/icons/icon1Password.vue create mode 100644 frontend/src/shared/components/icons/iconLastPassAuth.vue diff --git a/frontend/package.json b/frontend/package.json index e248b37..1988b39 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/styles/modules/components/vault.css b/frontend/src/app/styles/modules/components/vault.css index 2d2a3c5..5421ecd 100644 --- a/frontend/src/app/styles/modules/components/vault.css +++ b/frontend/src/app/styles/modules/components/vault.css @@ -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; } /* 消息提示磨砂风格 (全局样式注入建议) */ diff --git a/frontend/src/features/migration/composables/useDataExport.js b/frontend/src/features/migration/composables/useDataExport.js index ea33ab1..475b53c 100644 --- a/frontend/src/features/migration/composables/useDataExport.js +++ b/frontend/src/features/migration/composables/useDataExport.js @@ -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 diff --git a/frontend/src/features/migration/composables/useDataImport.js b/frontend/src/features/migration/composables/useDataImport.js index 7032e61..21f1037 100644 --- a/frontend/src/features/migration/composables/useDataImport.js +++ b/frontend/src/features/migration/composables/useDataImport.js @@ -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 { diff --git a/frontend/src/features/migration/service/dataMigrationService.js b/frontend/src/features/migration/service/dataMigrationService.js index e72d78b..3354003 100644 --- a/frontend/src/features/migration/service/dataMigrationService.js +++ b/frontend/src/features/migration/service/dataMigrationService.js @@ -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} + */ + 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 diff --git a/frontend/src/features/migration/views/dataExport.vue b/frontend/src/features/migration/views/dataExport.vue index 405b5ef..b7221e5 100644 --- a/frontend/src/features/migration/views/dataExport.vue +++ b/frontend/src/features/migration/views/dataExport.vue @@ -40,7 +40,7 @@ {{ $t('migration.migrate_ga') }} - + Bitwarden Auth (.json) @@ -59,7 +59,7 @@ {{ $t('migration.otpauth_txt') }} - + {{ $t('migration.spreadsheet_csv') }} diff --git a/frontend/src/features/migration/views/dataImport.vue b/frontend/src/features/migration/views/dataImport.vue index 4e7975f..274c9d3 100644 --- a/frontend/src/features/migration/views/dataImport.vue +++ b/frontend/src/features/migration/views/dataImport.vue @@ -61,6 +61,7 @@
Bitwarden Vault (.json/.csv) + {{ $t('migration.onepassword_format') }}
@@ -78,6 +79,7 @@ Ente Auth(.txt) Google Auth (.png/.jpg) Microsoft Auth (PhoneFactor) + LastPass Auth (.json) @@ -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']) diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index e2c3285..3a68468 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -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", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 3a1a45d..696de3d 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -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": "没有可导出的账号", diff --git a/frontend/src/shared/components/icons/icon1Password.vue b/frontend/src/shared/components/icons/icon1Password.vue new file mode 100644 index 0000000..3db208a --- /dev/null +++ b/frontend/src/shared/components/icons/icon1Password.vue @@ -0,0 +1,8 @@ + + diff --git a/frontend/src/shared/components/icons/iconLastPassAuth.vue b/frontend/src/shared/components/icons/iconLastPassAuth.vue new file mode 100644 index 0000000..b954ff1 --- /dev/null +++ b/frontend/src/shared/components/icons/iconLastPassAuth.vue @@ -0,0 +1,12 @@ + + diff --git a/frontend/src/shared/components/icons/iconMicrosoftAuth.vue b/frontend/src/shared/components/icons/iconMicrosoftAuth.vue index 2c8c9ab..a74b7b4 100644 --- a/frontend/src/shared/components/icons/iconMicrosoftAuth.vue +++ b/frontend/src/shared/components/icons/iconMicrosoftAuth.vue @@ -1,8 +1,8 @@