diff --git a/.gitignore b/.gitignore
index 4ce6b66..9a05614 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ logs
.actrc
.vscode/*
.devcontainer/*
+.test/*
backend/.dev.vars
backend/.wrangler
diff --git a/frontend/package.json b/frontend/package.json
index bc0a68e..bad1552 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
diff --git a/frontend/public/argon2.wasm b/frontend/public/argon2.wasm
new file mode 100755
index 0000000..75c3111
Binary files /dev/null and b/frontend/public/argon2.wasm differ
diff --git a/frontend/src/features/migration/composables/useDataImport.js b/frontend/src/features/migration/composables/useDataImport.js
index 523545c..b52db20 100644
--- a/frontend/src/features/migration/composables/useDataImport.js
+++ b/frontend/src/features/migration/composables/useDataImport.js
@@ -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) {
diff --git a/frontend/src/features/migration/service/dataMigrationService.js b/frontend/src/features/migration/service/dataMigrationService.js
index 9c5d781..790bcfe 100644
--- a/frontend/src/features/migration/service/dataMigrationService.js
+++ b/frontend/src/features/migration/service/dataMigrationService.js
@@ -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)
diff --git a/frontend/src/features/migration/views/dataImport.vue b/frontend/src/features/migration/views/dataImport.vue
index 35fc803..2451bc5 100644
--- a/frontend/src/features/migration/views/dataImport.vue
+++ b/frontend/src/features/migration/views/dataImport.vue
@@ -41,6 +41,7 @@
2FAS (.2fas)
Aegis (.json/.txt)
Bitwarden Auth (.json/.csv)
+ Proton Auth (.json)
Google Auth (.png/.jpg)
Microsoft Auth (PhoneFactor)
@@ -97,6 +98,7 @@
+
@@ -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'])
diff --git a/frontend/src/features/vault/composables/useVaultActions.js b/frontend/src/features/vault/composables/useVaultActions.js
index 2c57820..5b35308 100644
--- a/frontend/src/features/vault/composables/useVaultActions.js
+++ b/frontend/src/features/vault/composables/useVaultActions.js
@@ -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 已复制')
}
diff --git a/frontend/src/features/vault/views/home.vue b/frontend/src/features/vault/views/home.vue
index 2ae7050..0a75bab 100644
--- a/frontend/src/features/vault/views/home.vue
+++ b/frontend/src/features/vault/views/home.vue
@@ -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)
// 操作成功后:跳回账户列表并刷新数据
diff --git a/frontend/src/shared/components/icons/iconProtonAuth.vue b/frontend/src/shared/components/icons/iconProtonAuth.vue
new file mode 100644
index 0000000..7b10428
--- /dev/null
+++ b/frontend/src/shared/components/icons/iconProtonAuth.vue
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/shared/utils/serializers/protonStrategy.js b/frontend/src/shared/utils/serializers/protonStrategy.js
new file mode 100644
index 0000000..e8230f7
--- /dev/null
+++ b/frontend/src/shared/utils/serializers/protonStrategy.js
@@ -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');
+ }
+ }
+};
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 116913d..149503a 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -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 })],