feat: 数据导入新增支持 Proton Auth

This commit is contained in:
nap0o committed 2026-03-03 14:02:15 +08:00
1 parent 5ab24ee862
commit 3e5e1afcfe
11 files changed
+158 -13

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ logs
.actrc
.vscode/*
.devcontainer/*
.test/*
backend/.dev.vars
backend/.wrangler
+5 -1
View File
@@ -11,6 +11,8 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tanstack/vue-query": "^5.66.0",
"argon2-browser": "^1.18.0",
"bcryptjs": "^3.0.3",
"element-plus": "^2.9.5",
"jsqr": "^1.4.0",
"pinia": "^3.0.4",
@@ -27,7 +29,9 @@
"unplugin-auto-import": "^0.19.0",
"unplugin-vue-components": "^0.28.0",
"vite": "^6.2.0",
"vite-plugin-pwa": "^0.21.1"
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0"
},
"overrides": {
"serialize-javascript": "^7.0.3"
BIN
View File
Binary file not shown.
@@ -98,7 +98,7 @@ export function useDataImport(emitFn) {
try {
currentFile.value = file
const reader = new FileReader()
// 无论文件名为何,都先读取成二进制,再由 detectFileType 判断类型
reader.onload = async (event) => {
const buffer = event.target.result
@@ -124,7 +124,7 @@ export function useDataImport(emitFn) {
}
}
if (detectedType === 'encrypted' || detectedType === 'aegis_encrypted') {
if (detectedType === 'encrypted' || detectedType === 'aegis_encrypted' || detectedType === 'proton') {
importPassword.value = ''
isDialogHandled.value = false
showDecryptDialog.value = true
@@ -218,7 +218,7 @@ export function useDataImport(emitFn) {
}
// 先在前端做简单的去重处理(大小写/空格不敏感),避免重复发送相同条目
const normalize = (s, a) => `${(s||'').toString().trim().toLowerCase()}:${(a||'').toString().trim().toLowerCase()}`;
const normalize = (s, a) => `${(s || '').toString().trim().toLowerCase()}:${(a || '').toString().trim().toLowerCase()}`;
const seen = new Set();
const filteredVault = [];
for (const acc of batchAccumulatedVault.value) {
@@ -5,6 +5,7 @@ import { tryParseJSON, base64ToBytes } 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 { migrationError } from '@/shared/utils/errors/migrationError'
import jsQR from 'jsqr'
import initSqlJs from 'sql.js'
@@ -74,6 +75,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'
}
}
@@ -578,6 +580,13 @@ export const dataMigrationService = {
type = 'raw'
}
if (type === 'proton') {
rawVault = await protonStrategy.parse(content, password)
type = 'raw'
content = JSON.stringify(rawVault)
password = undefined
}
if (type === 'aegis_encrypted') {
const parsedAegis = tryParseJSON(content)
const decryptedDb = await aegisStrategy.decryptDatabase(parsedAegis, password)
@@ -41,6 +41,7 @@
<el-tag type="info" effect="light"><icon2FAS /> 2FAS (.2fas)</el-tag>
<el-tag type="info" effect="light"><iconAegis /> Aegis (.json/.txt)</el-tag>
<el-tag type="info" effect="light"><iconBitwarden /> Bitwarden Auth (.json/.csv)</el-tag>
<el-tag type="info" effect="light"><iconProtonAuth /> Proton Auth (.json)</el-tag>
<el-tag type="info" effect="light"><iconGoogleAuth /> Google Auth (.png/.jpg)</el-tag>
<el-tag type="info" effect="light"><iconMicrosoftAuth /> Microsoft Auth (PhoneFactor)</el-tag>
</div>
@@ -97,6 +98,7 @@
<!-- 加密文件密码输入弹窗 -->
<el-dialog v-model="showDecryptDialog" title="🔓 解密备份文件" width="400px" @close="handleDecryptDialogClose" destroy-on-close>
<el-alert v-if="currentImportType === 'aegis_encrypted'" title="检测到 Aegis 加密备份" type="warning" :closable="false" style="margin-bottom: 15px;" />
<el-alert v-else-if="currentImportType === 'proton'" title="检测到 Proton Pass 加密备份" type="warning" :closable="false" style="margin-bottom: 15px;" />
<el-alert v-else title="检测到本系统加密备份" type="success" :closable="false" style="margin-bottom: 15px;" />
<el-form label-position="top" v-loading="isDecrypting" :element-loading-text="loadingText">
<el-form-item label="请输入该备份的解密密码:">
@@ -121,6 +123,7 @@ 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 iconProtonAuth from '@/shared/components/icons/iconProtonAuth.vue'
const emit = defineEmits(['success'])
@@ -127,10 +127,9 @@ export function useVaultActions(fetchVault, vault) {
showSecret.value = false
showQrDialog.value = true
const algorithm = vaultItem.algorithm
? vaultItem.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-')
: 'SHA-1'
const uri = `otpauth://totp/${encodeURIComponent(vaultItem.service)}:${encodeURIComponent(vaultItem.account)}?secret=${vaultItem.secret}&issuer=${encodeURIComponent(vaultItem.service)}&algorithm=${algorithm}&digits=${vaultItem.digits || 6}&period=${vaultItem.period || 30}`
// otpauth URI spec: algorithm 参数不带横线,即 SHA1/SHA256/SHA512
const algorithm = (vaultItem.algorithm || 'SHA1').replace(/-/g, '').toUpperCase()
const uri = `otpauth://totp/${encodeURIComponent(vaultItem.service)}:${encodeURIComponent(vaultItem.account)}?secret=${encodeURIComponent(vaultItem.secret)}&issuer=${encodeURIComponent(vaultItem.service)}&algorithm=${algorithm}&digits=${vaultItem.digits || 6}&period=${vaultItem.period || 30}`
qrCodeUrl.value = await QRCode.toDataURL(uri, { width: 240, margin: 1 })
}
@@ -144,10 +143,8 @@ export function useVaultActions(fetchVault, vault) {
const copyOtpUrl = () => {
if (currentQrItem.value) {
const item = currentQrItem.value
const algorithm = item.algorithm
? item.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-')
: 'SHA-1'
const uri = `otpauth://totp/${encodeURIComponent(item.service)}:${encodeURIComponent(item.account)}?secret=${item.secret}&issuer=${encodeURIComponent(item.service)}&algorithm=${algorithm}&digits=${item.digits || 6}&period=${item.period || 30}`
const algorithm = (item.algorithm || 'SHA1').replace(/-/g, '').toUpperCase()
const uri = `otpauth://totp/${encodeURIComponent(item.service)}:${encodeURIComponent(item.account)}?secret=${encodeURIComponent(item.secret)}&issuer=${encodeURIComponent(item.service)}&algorithm=${algorithm}&digits=${item.digits || 6}&period=${item.period || 30}`
copyToClipboard(uri)
ElMessage.success('OTP URI 已复制')
}
+7 -1
View File
@@ -59,7 +59,13 @@ const DataImport = defineAsyncComponent(() => import('@/features/migration/vi
const DataBackup = defineAsyncComponent(() => import('@/features/backup/views/dataBackup.vue'))
const UtilityTools = defineAsyncComponent(() => import('@/features/tools/views/utilityTools.vue'))
const activeTab = ref('vault')
const sessionKey = 'activeTab'
const activeTab = ref(sessionStorage.getItem(sessionKey) || 'vault')
watch(activeTab, (newVal) => {
sessionStorage.setItem(sessionKey, newVal)
})
const vaultListRef = ref(null)
// 操作成功后:跳回账户列表并刷新数据
@@ -0,0 +1,16 @@
<template>
<svg fill="none" width="1em" height="1em" viewBox="0 0 482 482" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M282.973 219.186c0 16.827-9.934 31.28-24.2 37.973v29.894c0 9.8-7.934 17.733-17.774 17.733-9.826 0-17.72-7.933-17.72-17.733v-29.894c-14.306-6.693-24.186-21.146-24.186-37.973 0-23.186 18.773-41.96 41.906-41.96 23.147 0 41.974 18.774 41.974 41.96m76.267 72.054c3.892-9.227 6.706-18.774 8.372-28.467-1.626 9.733-4.426 19.24-8.373 28.466zm10.266-50.28c0 1.426-.04 2.893-.094 4.32-.093 3.893-.426 7.8-.946 11.64.48-3.947.8-7.84.906-11.787a61 61 0 0 0 .134-4.174z"></path>
<path fill="currentColor" d="M296.8 56.8c0 1.04-.054 2.04-.107 3.093 0 .52-.04 1.04-.134 1.56-.053 1.094-.2 2.147-.333 3.24a38 38 0 0 1-.573 3.32c0 .387-.147.72-.24 1a32 32 0 0 1-.707 2.853c-.333 1.374-.813 2.707-1.293 3.987-.28.907-.654 1.76-.987 2.613-.2.48-.387.907-.573 1.294-.48 1.04-.947 2.04-1.48 2.986-.52 1-1.094 2-1.654 2.947a80 80 0 0 1-3 4.427 57 57 0 0 1-4.04 4.8c-.853.893-1.706 1.8-2.613 2.613-.947.853-1.853 1.707-2.853 2.467a56 56 0 0 1-9.36 6.226h-.054c-14.68-9.413-30.12-17.013-46.146-22.906a250 250 0 0 0-16.107-5.28c-2.76-.8-5.466-1.52-8.213-2.187-37.84-9.453-77.52-9.546-115.44-.373-3.093.76-6.227 1.613-9.32 2.466A296 296 0 0 0 62.36 80.8a206 206 0 0 1 8.933-9.507c1.613-1.573 3.173-3.093 4.747-4.613 2.906-2.747 5.8-5.413 8.84-7.933 1.573-1.374 3.133-2.654 4.706-3.987a202 202 0 0 1 9.027-6.987c.187-.146.333-.24.48-.386 1.667-1.134 3.28-2.374 4.893-3.467 11.12-7.747 22.76-14.493 34.787-20.147 3.427-1.613 6.893-3.093 10.413-4.56 1.8-.76 3.654-1.48 5.507-2.186 1.76-.72 3.56-1.334 5.32-2 1.813-.614 3.667-1.24 5.467-1.854 1.813-.613 3.666-1.186 5.426-1.76 1.8-.52 3.654-1.04 5.454-1.573a246 246 0 0 1 22.24-5.08c1.866-.333 3.72-.613 5.613-.907 11.213-1.76 22.533-2.653 33.893-2.8.987 0 1.947-.053 2.893-.053.947 0 1.947.053 2.894.053C273.413 2.52 296.839 26.907 296.8 56.8"></path>
<path fill="currentColor" d="M296.8 56.8c0 1.04-.054 2.04-.107 3.093a9 9 0 0 1-.133 1.56c-.054 1.094-.2 2.147-.334 3.24a38 38 0 0 1-.573 3.32c0 .387-.147.72-.24 1a32 32 0 0 1-.707 2.853 35 35 0 0 1-1.293 3.987c-.28.907-.653 1.76-.987 2.613a42 42 0 0 1-2.053 4.28c-.52 1-1.093 2-1.653 2.947a57.4 57.4 0 0 1-7.04 9.227c-.854.893-1.707 1.8-2.614 2.613-.946.853-1.853 1.707-2.853 2.467a56 56 0 0 1-9.36 6.226h-.053a236.5 236.5 0 0 0-70.467-30.373 242 242 0 0 0-124.76 2.093A296 296 0 0 0 62.36 80.8a206 206 0 0 1 13.68-14.12c2.907-2.747 5.8-5.413 8.84-7.933 1.574-1.374 3.134-2.654 4.707-3.987a202 202 0 0 1 9.027-6.987c.186-.146.333-.24.48-.386 1.666-1.134 3.28-2.374 4.893-3.467a238 238 0 0 1 34.786-20.147 251 251 0 0 1 15.92-6.746c1.76-.72 3.56-1.334 5.32-2l5.467-1.854c1.813-.613 3.667-1.186 5.427-1.76 1.8-.52 3.653-1.04 5.453-1.573a246 246 0 0 1 22.24-5.08c1.867-.333 3.72-.613 5.613-.907 11.214-1.76 22.534-2.653 33.894-2.8.986 0 1.946-.053 2.893-.053s1.947.053 2.893.053C273.576 2.547 296.859 27.08 296.8 56.8Z" stroke="currentColor"></path>
<path fill="currentColor" d="M480.999 238.106v5.8c-.147 11.36-1.04 22.667-2.8 33.88-.24 1.907-.574 3.76-.907 5.613-1.333 7.454-3 14.867-5.08 22.24-.533 1.8-1 3.654-1.573 5.467-.574 1.76-1.147 3.613-1.76 5.413-.574 1.854-1.24 3.667-1.853 5.467-.667 1.76-1.28 3.56-2 5.32q-1.048 2.778-2.187 5.52a206 206 0 0 1-4.56 10.4 234.6 234.6 0 0 1-20.147 34.786c-1.093 1.627-2.28 3.24-3.466 4.907-.147.133-.24.28-.334.467-2.28 3-4.613 6.08-7.04 9.026-1.333 1.574-2.613 3.147-3.946 4.707-2.56 3.04-5.227 5.947-7.974 8.84-1.52 1.573-3.053 3.133-4.613 4.76-27.373 27.373-59.787 46.813-94.2 58.16 56.133-62.44 73.76-147.226 52.893-223.6 0-.04-.04-.093-.04-.146.094-1.374.147-2.747.147-4.174 0-30.8 24.947-55.746 55.747-55.746 29.84-.053 54.213 23.386 55.693 52.893"></path>
<path fill="currentColor" stroke="currentColor" d="M480.999 238.106v5.8c-.146 11.36-1.04 22.667-2.8 33.88-.24 1.907-.573 3.76-.906 5.613-1.334 7.454-3 14.867-5.08 22.24-.534 1.8-1 3.654-1.574 5.467-.573 1.76-1.146 3.613-1.76 5.413-.573 1.854-1.24 3.667-1.853 5.467-.667 1.76-1.28 3.56-2 5.32a183 183 0 0 1-2.187 5.52 234.6 234.6 0 0 1-24.706 45.186c-1.094 1.627-2.28 3.24-3.467 4.907q-.211.201-.333.467c-2.28 3-4.614 6.08-7.04 9.026-1.334 1.574-2.614 3.147-3.947 4.707-2.56 3.04-5.227 5.947-7.973 8.84l-4.614 4.76a238.85 238.85 0 0 1-94.2 58.16c56.134-62.44 73.76-147.226 52.894-223.6 0-.04-.04-.093-.04-.146.093-1.374.147-2.747.147-4.174a55.733 55.733 0 0 1 55.746-55.746c29.7-.044 54.207 23.23 55.693 52.893Z"></path>
<path fill="currentColor" d="M316.52 468.879c-83.414 27.52-178.88 8.133-245.227-58.16-90.626-90.64-93.626-235.72-8.933-329.92 3.04-1 6.133-1.946 9.213-2.853 3.093-.853 6.227-1.706 9.32-2.466 37.92-9.227 77.6-9.133 115.44.373 2.747.667 5.466 1.387 8.213 2.187a250 250 0 0 1 16.12 5.28c16.013 5.893 31.453 13.493 46.147 22.906-.96.52-1.96 1-2.947 1.467-1 .387-2 .813-3 1.187-1 .386-2.04.773-3.093 1.093-.04 0-.094 0-.134.053a56.6 56.6 0 0 1-13.12 2.374 74 74 0 0 1-3.466.093c-32.894 0-65.773 12.547-90.867 37.64-50.146 50.187-50.186 131.506 0 181.693 50.187 50.186 131.547 50.133 181.68 0 1.573-1.573 3.093-3.133 4.56-4.8 2.76-3.04 5.333-6.187 7.746-9.453a275 275 0 0 0 3.574-5.04c1.133-1.667 2.226-3.427 3.28-5.134 1.04-1.76 2.04-3.52 2.986-5.28 1-1.8 1.907-3.613 2.76-5.413.854-1.813 1.707-3.613 2.467-5.467a129 129 0 0 0 8.373-28.466c.32-1.947.614-3.894.854-5.854.466-3.933.8-7.84.893-11.773 0 .04.053.093.053.133 20.867 76.374 3.227 161.16-52.893 223.6z"></path>
<path stroke="currentColor" d="M316.52 468.879c-83.413 27.52-178.88 8.133-245.226-58.16-90.627-90.64-93.627-235.72-8.934-329.92 3.04-1 6.134-1.946 9.214-2.853a240.64 240.64 0 0 1 149.092 5.374 237.3 237.3 0 0 1 46.146 22.906 61 61 0 0 1-2.947 1.467c-1 .387-2 .813-3 1.187a45 45 0 0 1-3.093 1.093c-.04 0-.093 0-.133.053a56.6 56.6 0 0 1-16.587 2.467 128.1 128.1 0 0 0-90.866 37.64c-50.147 50.187-50.187 131.506 0 181.693 50.186 50.186 131.546 50.133 181.68 0a121 121 0 0 0 12.306-14.253 139 139 0 0 0 6.853-10.174 151 151 0 0 0 5.747-10.693c.853-1.813 1.707-3.613 2.467-5.467a129 129 0 0 0 9.226-34.32c.467-3.933.8-7.84.894-11.773 0 .04.053.093.053.133 20.867 76.374 3.227 161.16-52.893 223.6Z"></path>
<path stroke="currentColor" d="M282.973 219.186c0 16.827-9.934 31.28-24.2 37.973v29.894c0 9.8-7.934 17.733-17.774 17.733-9.826 0-17.72-7.933-17.72-17.733v-29.894c-14.306-6.693-24.186-21.146-24.186-37.973 0-23.186 18.773-41.96 41.906-41.96 23.147 0 41.974 18.774 41.974 41.96Z"></path>
<path stroke="currentColor" d="M282.973 219.186c0 16.827-9.934 31.28-24.2 37.973v29.894c0 9.8-7.934 17.733-17.774 17.733-9.826 0-17.72-7.933-17.72-17.733v-29.894c-14.306-6.693-24.186-21.146-24.186-37.973 0-23.186 18.773-41.96 41.906-41.96 23.147 0 41.974 18.774 41.974 41.96Z"></path>
</svg>
</template>
<script>
export default { name: 'iconProtonAuth' }
</script>
@@ -0,0 +1,105 @@
import argon2 from 'argon2-browser/dist/argon2-bundled.min.js';
import { parseOtpUri } from '../totp';
export default {
name: 'Proton Pass (.json)',
fileType: 'application/json, text/plain',
async parse(fileContent, password) {
let jsonPayload;
try {
jsonPayload = JSON.parse(fileContent);
} catch (e) {
throw new Error('INVALID_FORMAT_OR_PASSWORD');
}
if (jsonPayload.version !== 1 || !jsonPayload.salt || !jsonPayload.content) {
throw new Error('INVALID_FORMAT_OR_PASSWORD');
}
if (!password) {
throw new Error('PASSWORD_REQUIRED');
}
try {
// Decode base64 in browser
const saltStr = atob(jsonPayload.salt);
const salt = new Uint8Array(saltStr.length);
for (let i = 0; i < saltStr.length; i++) {
salt[i] = saltStr.charCodeAt(i);
}
const contentStr = atob(jsonPayload.content);
const content = new Uint8Array(contentStr.length);
for (let i = 0; i < contentStr.length; i++) {
content[i] = contentStr.charCodeAt(i);
}
// Extract AES-GCM components
const iv = content.slice(0, 12);
// WebCrypto decrypter expects ciphertext followed by auth tag in one buffer
const cipherAndTag = content.slice(12);
// 1. Derive key via Argon2id
const argon2Result = await argon2.hash({
pass: password,
salt: salt,
time: 2,
mem: 19 * 1024,
hashLen: 32,
parallelism: 1,
type: argon2.ArgonType.Argon2id,
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(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['decrypt']
);
// AAD defined in Rust
const aad = new TextEncoder().encode('proton.authenticator.export.v1');
const decryptedBuffer = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: aad,
tagLength: 128
},
cryptoKey,
cipherAndTag
);
const decryptedString = new TextDecoder().decode(decryptedBuffer);
const decryptedJson = JSON.parse(decryptedString);
// 3. Parse items
const entries = decryptedJson.entries || [];
const results = [];
for (const entry of entries) {
if (entry.content && entry.content.uri) {
const parsed = parseOtpUri(entry.content.uri);
if (parsed) {
results.push(parsed);
}
}
}
return results;
} catch (err) {
console.error('Proton decryption failed:', err);
// Must throw this precise message so UI prompts for password again if wrong
throw new Error('INVALID_FORMAT_OR_PASSWORD');
}
}
};
+4
View File
@@ -7,6 +7,8 @@ import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { execSync } from 'child_process'
import wasm from 'vite-plugin-wasm'
import topLevelAwait from 'vite-plugin-top-level-await'
let commitHash = ''
try {
@@ -18,6 +20,8 @@ try {
export default defineConfig({
plugins: [
vue(),
wasm(),
topLevelAwait(),
AutoImport({
imports: ['vue', 'vue-router'],
resolvers: [ElementPlusResolver({ importStyle: false })],