feat: 支持 Bitwarden 备份数据导入
This commit is contained in:
1 parent
9905d78e1a
commit
cfa32d354a
11 files changed
+251
-63
No files matched your search
@@ -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 .
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user