feat: 支持 Bitwarden 备份数据导入

This commit is contained in:
nap0o committed 2026-03-15 18:23:42 +08:00
1 parent 9905d78e1a
commit cfa32d354a
11 files changed
+251 -63

No files matched your search

+2 -2
View File
@@ -143,8 +143,8 @@ jobs:
cd audit_temp
git init
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout --orphan security-audit
git add .
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
- name: Configure git
run: |
git config user.name "GitHub Action"
git config user.email "action@github.com"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Sync main from upstream (Surgical)
run: |
@@ -210,6 +210,7 @@
.migration-ga-tip span,
.migration-ms-tip span {
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
@@ -73,7 +73,7 @@ export function useDataExport() {
const variant = currentVariant.value
let password = ''
if (type === 'encrypted') {
if (type === '2fauth_encrypted') {
if (exportForm.value.password !== exportForm.value.confirm) {
return ElMessage.error(t('migration.password_mismatch'))
}
@@ -94,7 +94,7 @@ export function useDataExport() {
throw new Error(t('migration.no_accounts_to_export'))
}
loadingText.value = type === 'encrypted' ? t('migration.encrypting') : t('migration.generating_file')
loadingText.value = type === '2fauth_encrypted' ? t('migration.encrypting') : t('migration.generating_file')
// 特殊处理 Google Auth (不再直接生成,而是进入选择界面)
if (type === 'gauth') {
@@ -107,7 +107,7 @@ export function useDataExport() {
return
}
if (type === 'html') {
if (type === '2fauth_html') {
const htmlContent = await dataMigrationService.exportAsHtml(vault)
downloadBlob(htmlContent, `2fauth-worker-export-html-${new Date().toISOString().split('T')[0]}.html`, 'text/html')
ElMessage.success(t('migration.export_success'))
@@ -121,7 +121,7 @@ export function useDataExport() {
const date = new Date().toISOString().split('T')[0]
switch (type) {
case 'text':
case 'generic_text':
mimeType = 'text/plain'
filename = `2fauth-worker-export-otpauth-${date}.txt`
break
@@ -129,7 +129,7 @@ export function useDataExport() {
mimeType = 'text/csv'
filename = `2fauth-worker-export-csv-${date}.csv`
break
case 'bwauth':
case 'bitwarden_auth':
mimeType = 'application/json'
filename = `2fauth-worker-export-bitwarden-auth-${date}.json`
break
@@ -141,10 +141,18 @@ export function useDataExport() {
mimeType = 'application/json'
filename = `2fauth-worker-export-generic-json-${date}.json`
break
default:
// Covers 'encrypted', 'json', 'aegis'
case '2fauth_encrypted':
mimeType = 'application/json'
filename = `2fauth-worker-export-${type}-${date}.json`
filename = `2fauth-worker-backup-encrypted-${date}.json`
break
case '2fauth_json':
mimeType = 'application/json'
filename = `2fauth-worker-backup-json-${date}.json`
break
default:
// Covers '2fauth_html', 'aegis' etc
mimeType = (type === '2fauth_html') ? 'text/html' : 'application/json'
filename = `2fauth-worker-export-${type.replace('2fauth_', '')}-${date}.${(type === '2fauth_html') ? 'html' : 'json'}`
break
}
@@ -182,7 +182,7 @@ export function useDataImport(emitFn) {
}
}
if (detectedType === 'encrypted' || detectedType === 'aegis_encrypted' || detectedType === 'proton_encrypted' || detectedType === '2fas_encrypted' || detectedType === 'ente_encrypted') {
if (detectedType === '2fauth_encrypted' || detectedType === 'aegis_encrypted' || detectedType === 'proton_encrypted' || detectedType === '2fas_encrypted' || detectedType === 'ente_encrypted' || detectedType === 'bitwarden_vault_encrypted') {
importPassword.value = ''
isDialogHandled.value = false
showDecryptDialog.value = true
@@ -33,7 +33,7 @@ export const dataMigrationService = {
* 智能识别导入内容或文件的类型
* @param {string|ArrayBuffer|Uint8Array} content - 文件文本内容或二进制数据
* @param {string} filename - 文件名
* @returns {'bwauth_csv'|'generic_csv'|'text'|'bwauth_json'|'encrypted'|'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'|'unknown'} 返回类型标识
*/
detectFileType(content, filename) {
// 如果是二进制数据,尝试用更宽容的方式判断 PhoneFactor
@@ -72,20 +72,24 @@ 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 'bwauth_csv'
if (firstLine.includes('login_totp')) return 'bitwarden_vault_csv'
if (firstLine.includes('otpauth')) return 'bitwarden_auth_csv' // Bitwarden Auth standalone typically exports simpler CSV
return 'generic_csv'
}
if (typeof textContent === 'string' && textContent.trim().startsWith('otpauth://')) {
return 'text'
return 'generic_text'
}
if (typeof textContent === 'string') {
const json = tryParseJSON(textContent)
if (json) {
if (Array.isArray(json.items) && json.items.length > 0 && json.items[0].login && json.items[0].login.totp) return 'bwauth_json'
if (json.encrypted === true && json.app === '2fauth') return 'encrypted'
if (json.app === '2fauth' || Array.isArray(json.accounts) || Array.isArray(json.vault) || Array.isArray(json.secrets)) return 'json'
// 如果包含 folders 字段,通常是 Bitwarden Vault (密码管理器)
if (Array.isArray(json.items) && Array.isArray(json.folders)) return 'bitwarden_vault_json'
// 如果只包含 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'
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'
if (json.schemaVersion && Array.isArray(json.services)) return '2fas'
@@ -94,13 +98,15 @@ export const dataMigrationService = {
if (json.version === 1 && typeof json.salt === 'string' && typeof json.content === 'string') return 'proton_encrypted'
// Ente Auth 加密导出:同时包含 kdfParams 和 encryptedData 两个关键字段
if (json.kdfParams && typeof json.encryptedData === 'string') return 'ente_encrypted'
// Bitwarden password-protected export (Vault)
if (json.encrypted === true && json.passwordProtected === true && json.encKeyValidation_DO_NOT_EDIT) return 'bitwarden_vault_encrypted'
}
}
if (filename) {
const ext = filename.toLowerCase()
if (ext.endsWith('.2fas')) return '2fas'
if (ext.endsWith('.txt')) return 'text'
if (ext.endsWith('.txt')) return 'generic_text'
}
return 'unknown'
@@ -128,7 +134,7 @@ export const dataMigrationService = {
const timestamp = new Date().toISOString()
const baseData = { version: "2.0", app: "2fauth", timestamp }
if (type === 'encrypted') {
if (type === '2fauth_encrypted') {
if (!password) throw new migrationError('加密导出需要密码', 'MISSING_PASSWORD')
const payload = { ...baseData, accounts: vault }
const encryptedData = await encryptDataWithPassword(payload, password)
@@ -158,7 +164,7 @@ export const dataMigrationService = {
}, null, 2)
}
if (type === 'json') {
if (type === '2fauth_json') {
return JSON.stringify({ ...baseData, encrypted: false, accounts: vault }, null, 2)
}
@@ -223,7 +229,7 @@ export const dataMigrationService = {
}, null, 2)
}
if (type === 'text') {
if (type === 'generic_text') {
return vault.map(acc => {
const label = encodeURIComponent(`${acc.service}:${acc.account}`)
const issuer = encodeURIComponent(acc.service)
@@ -232,7 +238,7 @@ export const dataMigrationService = {
}
if (type === 'csv') {
if (variant === 'bitwarden') {
if (variant === 'bitwarden_auth') {
let csv = 'name,secret,totp,favorite,folder\n'
vault.forEach(acc => {
const name = `"${acc.service}${acc.account ? ':' + acc.account : ''}"`
@@ -242,19 +248,18 @@ export const dataMigrationService = {
csv += `${name},${acc.secret},${totp},0,\n`
})
return csv
} else {
// generic csv
let csv = 'name,issuer,secret,algorithm,digits,period,type\n'
vault.forEach(acc => {
const name = `"${acc.account}"`
const issuer = `"${acc.service}"`
csv += `${name},${issuer},${acc.secret},${acc.algorithm},${acc.digits},${acc.period},TOTP\n`
})
return csv
}
// generic csv
let csv = 'name,issuer,secret,algorithm,digits,period,type\n'
vault.forEach(acc => {
const name = `"${acc.account}"`
const issuer = `"${acc.service}"`
csv += `${name},${issuer},${acc.secret},${acc.algorithm},${acc.digits},${acc.period},TOTP\n`
})
return csv
}
if (type === 'bwauth') {
if (type === 'bitwarden_auth') {
const items = vault.map(acc => {
const label = encodeURIComponent(acc.account ? `${acc.service}:${acc.account}` : acc.service)
const issuer = encodeURIComponent(acc.service)
@@ -630,6 +635,112 @@ export const dataMigrationService = {
}
},
/**
* 3.1 解密 Bitwarden 密码保护的加密 JSON
* @param {string} password
* @param {Object} json
* @returns {Promise<Object>} 解密后的 JSON 对象
*/
async decryptBitwardenVaultEncrypted(password, json) {
try {
const saltStr = json.salt;
const iterations = json.kdfIterations;
const kdfType = json.kdfType;
if (kdfType !== 0) {
throw new migrationError(`不支持的 KDF 类型: ${kdfType}`, 'UNSUPPORTED_BITWARDEN_KDF');
}
// 1. 衍生主密钥: 关键是直接使用 salt 字符串作为 UTF-8 字节输入
const saltBytes = new TextEncoder().encode(saltStr);
const passwordBytes = new TextEncoder().encode(password);
const keyMaterialHandle = await crypto.subtle.importKey(
'raw', passwordBytes, { name: 'PBKDF2' }, false, ['deriveBits']
);
const keyMaterialBuffer = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: saltBytes, iterations, hash: 'SHA-256' },
keyMaterialHandle, 256
);
const keyMaterial = new Uint8Array(keyMaterialBuffer);
// 2. 衍生加密 Key 和 MAC Key (HKDF-Expand)
const encKey = await this._hkdfExpandSha256(keyMaterial, 'enc', 32);
const macKey = await this._hkdfExpandSha256(keyMaterial, 'mac', 32);
// 3. 校验并解密
const decryptStr = async (cipherString, ek, mk) => {
const parts = cipherString.split('.');
if (parts[0] !== '2') throw new Error('Unsupported encryption type');
const body = parts[1].split('|');
const iv = Uint8Array.from(atob(body[0]), c => c.charCodeAt(0));
const ct = Uint8Array.from(atob(body[1]), c => c.charCodeAt(0));
const mac = Uint8Array.from(atob(body[2]), c => c.charCodeAt(0));
// Verify MAC: hmacSha256(macKey, concatBytes(iv, cipher))
const hmacKeyHandle = await crypto.subtle.importKey(
'raw', mk, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const macInput = new Uint8Array(iv.length + ct.length);
macInput.set(iv, 0);
macInput.set(ct, iv.length);
const calculatedMac = new Uint8Array(await crypto.subtle.sign('HMAC', hmacKeyHandle, macInput));
// Constant time comparison (simple version)
if (mac.length !== calculatedMac.length) return null;
let diff = 0;
for (let i = 0; i < mac.length; i++) diff |= mac[i] ^ calculatedMac[i];
if (diff !== 0) return null;
// Decrypt
const aesKeyHandle = await crypto.subtle.importKey(
'raw', ek, { name: 'AES-CBC' }, false, ['decrypt']
);
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv }, aesKeyHandle, ct
);
return new TextDecoder().decode(decryptedBuffer);
};
// 先解密校验位
const validationResult = await decryptStr(json.encKeyValidation_DO_NOT_EDIT, encKey, macKey);
if (!validationResult) throw new Error('MAC verification failed');
// 解密主体数据
const plaintext = await decryptStr(json.data, encKey, macKey);
return JSON.parse(plaintext);
} catch (error) {
throw new migrationError(`Bitwarden 解密失败: ${error.message}`, 'BITWARDEN_DECRYPTION_FAILED', error);
}
},
/**
* HKDF-Expand (SHA-256) 浏览器 SubtleCrypto 实现
*/
async _hkdfExpandSha256(prk, info, length) {
const infoBytes = new TextEncoder().encode(info || '');
const prkHandle = await crypto.subtle.importKey(
'raw', prk, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const result = new Uint8Array(length);
let previous = new Uint8Array(0);
let offset = 0;
let counter = 1;
while (offset < length) {
const input = new Uint8Array(previous.length + infoBytes.length + 1);
input.set(previous, 0);
input.set(infoBytes, previous.length);
input[input.length - 1] = counter & 0xff;
previous = new Uint8Array(await crypto.subtle.sign('HMAC', prkHandle, input));
const copyLen = Math.min(previous.length, length - offset);
result.set(previous.slice(0, copyLen), offset);
offset += copyLen;
counter++;
}
return result;
},
/**
* 辅助函数:将字节数组编码为 Base32 字符串
* @param {Uint8Array} bytes
@@ -964,7 +1075,7 @@ export const dataMigrationService = {
// 对于非 phonefactor 的格式,后续逻辑全部使用 textContent 替代 content
content = textContent;
if (type === 'bwauth_csv' || type === 'generic_csv') {
if (type === 'bitwarden_auth_csv' || type === 'generic_csv') {
rawVault = csvStrategy.parseCsv(content)
content = JSON.stringify(rawVault)
type = 'raw'
@@ -1008,6 +1119,22 @@ export const dataMigrationService = {
}
}
// 处理 Bitwarden 加密导出 (Password-protected Vault)
if (type === 'bitwarden_vault_encrypted') {
if (!password) throw new migrationError('导入 Bitwarden 加密文件需要密码', 'MISSING_PASSWORD')
try {
const bitwardenJson = typeof content === 'string' ? JSON.parse(content) : content
const decrypted = await this.decryptBitwardenVaultEncrypted(password, bitwardenJson)
// 解密后的格式就是普通的 bitwarden_vault_json 结构(有 items 数组)
content = JSON.stringify(decrypted)
type = 'bitwarden_vault_json'
password = undefined
} catch (e) {
if (e instanceof migrationError) throw e
throw new migrationError(`Bitwarden 加密备份解密失败: ${e.message}`, 'BITWARDEN_DECRYPTION_FAILED', e)
}
}
if (type === 'aegis_encrypted') {
const parsedAegis = tryParseJSON(content)
const decryptedDb = await aegisStrategy.decryptDatabase(parsedAegis, password)
@@ -1019,7 +1146,7 @@ export const dataMigrationService = {
content = JSON.stringify(parsedAegis.db)
}
if (type === 'encrypted') {
if (type === '2fauth_encrypted') {
if (!password) throw new migrationError('导入本系统加密文件需要密码', 'MISSING_PASSWORD')
try {
let ciphertext = content
@@ -1031,7 +1158,7 @@ export const dataMigrationService = {
throw new migrationError('解密失败:密码错误或文件格式不兼容', 'DECRYPTION_FAILED', e)
}
}
else if (type === 'json') {
else if (type === '2fauth_json') {
const json = typeof content === 'string' ? JSON.parse(content) : content
if (Array.isArray(json.accounts)) rawVault = json.accounts
else if (Array.isArray(json.vault)) rawVault = json.vault
@@ -1072,17 +1199,33 @@ export const dataMigrationService = {
})
}
}
else if (type === 'bwauth_json') {
else if (type === 'bitwarden_vault_json' || type === 'bitwarden_auth_json') {
const json = typeof content === 'string' ? JSON.parse(content) : content
if (Array.isArray(json.items)) {
json.items.forEach(item => {
if (item.login && item.login.totp) {
const accInfo = parseOtpUri(item.login.totp)
if (accInfo) {
// 兼容模式:既支持 Vault 的 login.totp,也支持可能的直接 totp 字段或 URI
const totpRaw = (item.login && item.login.totp) || item.totp || item.uri || ''
if (totpRaw) {
let accInfo = parseOtpUri(totpRaw)
if (!accInfo) {
const secret = totpRaw.replace(/\s/g, '').toUpperCase()
const base32Re = /^[A-Z2-7]+=*$/
if (base32Re.test(secret)) {
accInfo = {
service: item.name || 'Unknown',
account: (item.login && item.login.username) || item.username || 'Unknown',
secret: secret,
algorithm: 'SHA-1',
digits: 6,
period: 30,
category: ''
}
}
} else {
accInfo.service = item.name || accInfo.service
accInfo.account = item.login.username || accInfo.account
rawVault.push(accInfo)
accInfo.account = (item.login && item.login.username) || item.username || accInfo.account
}
if (accInfo) rawVault.push(accInfo)
}
})
}
@@ -1099,14 +1242,14 @@ export const dataMigrationService = {
period: s.info?.period || 30,
}))
}
else if (type === 'text') {
else if (type === 'generic_text') {
const lines = content.split('\n')
lines.forEach(line => {
const acc = parseOtpUri(line.trim())
if (acc) rawVault.push(acc)
})
}
else if (type === 'bwauth_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
else if (type === 'bitwarden_vault_csv' || type === 'bitwarden_auth_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content)
return rawVault.map(acc => {
if (typeof acc.account === 'string' && acc.account.includes(':')) {
@@ -15,10 +15,10 @@
<span>{{ $t('migration.system_backup') }}</span>
</div>
<div class="migration-button-row">
<el-button plain @click="openExportDialog('encrypted')" class="migration-button-with-icon">
<el-button plain @click="openExportDialog('2fauth_encrypted')" class="migration-button-with-icon">
<el-icon><Lock /></el-icon> {{ $t('migration.encrypted_json') }}
</el-button>
<el-button plain @click="openWarningDialog('json')" class="migration-button-with-icon">
<el-button plain @click="openWarningDialog('2fauth_json')" class="migration-button-with-icon">
<el-icon><Unlock /></el-icon> {{ $t('migration.plaintext_json') }}
</el-button>
</div>
@@ -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('bwauth')" class="migration-button-with-icon">
<el-button plain @click="openWarningDialog('bitwarden_auth')" class="migration-button-with-icon">
<el-icon><iconBitwarden /></el-icon> Bitwarden Auth (.json)
</el-button>
</div>
@@ -56,13 +56,13 @@
<el-button plain @click="openWarningDialog('generic_json')" class="migration-button-with-icon">
<el-icon><Document /></el-icon> {{ $t('migration.generic_format') }} (.json)
</el-button>
<el-button plain @click="openWarningDialog('text')" class="migration-button-with-icon">
<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-icon><Grid /></el-icon> {{ $t('migration.spreadsheet_csv') }}
</el-button>
<el-button plain @click="openWarningDialog('html')" class="migration-button-with-icon">
<el-button plain @click="openWarningDialog('2fauth_html')" class="migration-button-with-icon">
<el-icon><Monitor /></el-icon> {{ $t('migration.html_page') }}
</el-button>
</div>
@@ -53,7 +53,18 @@
</div>
</div>
<!-- 组2: 移动端 App -->
<!-- 组2: 密码管理器 -->
<div class="ecosystem-group-card">
<h4 class="group-title">
<el-icon><Monitor /></el-icon>
{{ $t('migration.password_manager_format') }}
</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>
</div>
</div>
<!-- 组3: 移动端 2FA App -->
<div class="ecosystem-group-card">
<h4 class="group-title">
<el-icon><Iphone /></el-icon>
@@ -62,9 +73,9 @@
<div class="ecosystem-grid">
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><icon2FAS /></el-icon> 2FAS (.2fas)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconAegis /></el-icon> Aegis (.json/.txt)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconBitwarden /></el-icon> Bitwarden (.json/.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconProtonAuth /></el-icon> Proton (.json)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconEnte /></el-icon> Ente (.txt)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconBitwarden /></el-icon> Bitwarden Auth (.json/.csv)</el-button>
<el-button plain class="migration-button-with-icon" @click="triggerUpload"><el-icon><iconProtonAuth /></el-icon> Proton Auth(.json)</el-button>
<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>
</div>
@@ -85,7 +96,7 @@
</div>
<!-- 详细引导 -->
<el-divider border-style="dashed" content-position="left" class="mt-30">
<el-divider border-style="dashed" content-position="center" class="mt-30">
<span class="text-secondary font-12">{{ $t('migration.ga_ms_import_guide') }}</span>
</el-divider>
@@ -139,6 +150,7 @@
<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 === '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" />
<el-alert v-else :title="$t('migration.detect_system')" type="success" :closable="false" class="mb-15" />
<el-form label-position="top">
<el-form-item :label="$t('migration.input_decrypt_pwd_label')">
@@ -155,7 +167,7 @@
<script setup>
import { ref } from 'vue'
import { UploadFilled, Lock, Unlock, Document, Tickets, Grid, Warning, Folder, Iphone, QuestionFilled } from '@element-plus/icons-vue'
import { UploadFilled, Lock, Unlock, Document, Tickets, Grid, Warning, Folder, Iphone, QuestionFilled, Monitor } from '@element-plus/icons-vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useDataImport } from '@/features/migration/composables/useDataImport'
+2
View File
@@ -383,11 +383,13 @@
"detect_proton": "Proton Auth encrypted backup detected",
"detect_2fas": "2FAS Auth encrypted backup detected",
"detect_ente": "Ente Auth encrypted backup detected",
"detect_bitwarden_vault": "Bitwarden Vault encrypted backup detected",
"detect_system": "System encrypted backup detected",
"input_decrypt_pwd_label": "Please enter the decryption password:",
"input_decrypt_pwd_placeholder": "Enter the password set during export",
"confirm_decrypt_import": "Confirm Decrypt & Import",
"system_backup_format": "System Backup",
"password_manager_format": "Password Manager (Vault)",
"encrypted_backup_json": "Encrypted Backup (.json)",
"plaintext_backup_json": "Plaintext Backup (.json)",
"mobile_app_format": "Mobile 2FA Apps",
+2
View File
@@ -383,11 +383,13 @@
"detect_proton": "检测到 Proton Auth 加密备份",
"detect_2fas": "检测到 2FAS Auth 加密备份",
"detect_ente": "检测到 Ente Auth 加密备份",
"detect_bitwarden_vault": "检测到 Bitwarden Vault 加密备份",
"detect_system": "检测到本系统加密备份",
"input_decrypt_pwd_label": "请输入该备份的解密密码:",
"input_decrypt_pwd_placeholder": "输入当时设置的导出密码",
"confirm_decrypt_import": "确认解密并导入",
"system_backup_format": "本系统备份",
"password_manager_format": "密码管理器 (Vault)",
"encrypted_backup_json": "加密备份 (.json)",
"plaintext_backup_json": "明文备份 (.json)",
"mobile_app_format": "移动端 2FA App",
@@ -51,7 +51,7 @@ export const csvStrategy = {
},
/**
* Decode a generic or bwauth CSV string.
* Decode a generic or bitwarden (vault) CSV string.
* @param {string} csvText
* @returns {VaultItem[]}
*/
@@ -63,20 +63,40 @@ export const csvStrategy = {
const headers = this._splitCsvLine(lines[0]).map(h => h.toLowerCase())
const rawVault = []
const isBwAuth = headers.includes('login_totp')
const isBitwardenVault = headers.includes('login_totp')
const isBitwardenAuth = headers.includes('otpauth') // Bitwarden Auth standalone usually has simple column names
const isGeneric = headers.includes('issuer') || headers.includes('secret') || headers.includes('name')
if (!isBwAuth && !isGeneric) return []
if (!isBitwardenVault && !isBitwardenAuth && !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 (isBwAuth) {
const totpStr = rowData['login_totp'] || ''
if (totpStr.startsWith('otpauth://')) {
const accData = parseOtpUri(totpStr)
if (isBitwardenVault || isBitwardenAuth) {
const totpStr = (rowData['login_totp'] || rowData['otpauth'] || rowData['totp'] || '').trim()
if (totpStr) {
let accData = null
if (totpStr.startsWith('otpauth://')) {
accData = parseOtpUri(totpStr)
} else {
// Check if it's a plain Base32 secret
const secret = totpStr.replace(/\s/g, '').toUpperCase()
const base32Re = /^[A-Z2-7]+=*$/
if (base32Re.test(secret)) {
accData = {
service: rowData['name'] || 'Unknown',
account: rowData['login_username'] || 'Unknown',
secret: secret,
algorithm: 'SHA-1',
digits: 6,
period: 30,
category: ''
}
}
}
if (accData) {
accData.service = rowData['name'] || accData.service
accData.account = rowData['login_username'] || accData.account