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 })],