diff --git a/webapp/index.html b/webapp/index.html index e17be7b..14cd169 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -9,7 +9,7 @@ script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; - connect-src 'self'; + connect-src 'self' https://api.pwnedpasswords.com; font-src 'self'; form-action 'self'; base-uri 'self'; diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index e5ca15c..8073712 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -68,6 +68,7 @@ import { t } from '@/lib/i18n'; import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; import { clearOfflineUnlockRecord } from '@/lib/offline-auth'; +import { clearPasswordSecurityCache } from '@/lib/password-security-cache'; import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt'; import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker'; import { @@ -111,6 +112,7 @@ const APP_ROUTE_PATHS = [ '/', '/vault', '/vault/totp', + '/security/password-health', '/generator', '/sends', '/admin', @@ -386,6 +388,10 @@ export default function App() { } }, [phase, profile, session]); + useEffect(() => { + if (phase !== 'app') clearPasswordSecurityCache(); + }, [phase]); + useEffect(() => { if (typeof window === 'undefined') return; window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes)); @@ -872,6 +878,7 @@ export default function App() { setDecryptedFolders([]); setDecryptedCiphers([]); setDecryptedSends([]); + clearPasswordSecurityCache(); setUnlockPassword(''); setPendingTotp(null); setPendingTotpMode(null); @@ -893,6 +900,7 @@ export default function App() { setSession(null); clearProfileSnapshot(); clearOfflineUnlockRecord(); + clearPasswordSecurityCache(); setProfile(null); setUnlockPreparing(false); setPendingTotp(null); @@ -1910,6 +1918,7 @@ export default function App() { ? '/vault' : '/settings'; const currentPageTitle = (() => { + if (location === '/security/password-health') return t('txt_password_security'); if (location === '/vault/totp') return t('txt_verification_code'); if (location === '/generator') return t('txt_password_generator'); if (location === '/sends') return t('nav_sends'); diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx index 8c0d2ea..b7b9613 100644 --- a/webapp/src/components/AppAuthenticatedShell.tsx +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -1,4 +1,4 @@ -import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact'; +import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact'; import type { ComponentChildren } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; import { Link } from 'wouter'; @@ -55,7 +55,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) const isDomainRulesRoute = props.location === '/settings/domain-rules'; const isLogRoute = props.location === '/logs'; const isAdmin = isAdminProfile(props.profile); - const vaultActive = props.location === '/vault' || props.location === '/vault/totp'; + const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health'; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE; const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive; const flatSettingsActive = settingsActive && !deviceManagementActive; @@ -175,6 +175,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) <> {renderSideLink('/vault', props.location === '/vault', , t('nav_vault_items'))} {renderSideLink('/vault/totp', props.location === '/vault/totp', , t('txt_verification_code'))} + {renderSideLink('/security/password-health', props.location === '/security/password-health', , t('nav_password_security'))} {renderSideLink('/generator', props.location === '/generator', , t('nav_generator'))} {renderSideLink('/sends', props.location === '/sends', , t('nav_sends'))} {renderSideLink('/settings', flatSettingsActive, , t('txt_settings'))} @@ -196,6 +197,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) <> {renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))} {renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))} + {renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))} )} {renderSideLink('/generator', props.location === '/generator', , t('nav_generator'))} diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 7cbb663..bb2ff1e 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense } from 'preact/compat'; import { useEffect } from 'preact/hooks'; import { Link, Route, Switch } from 'wouter'; -import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; +import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import LoadingState from '@/components/LoadingState'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; @@ -14,6 +14,7 @@ import type { ExportRequest } from '@/lib/export-formats'; const VaultPage = lazy(() => import('@/components/VaultPage')); const SendsPage = lazy(() => import('@/components/SendsPage')); const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage')); +const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage')); const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage')); @@ -208,6 +209,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { return ( + + }> + + + }> @@ -334,6 +340,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { {t('nav_account_settings')} + + + {t('nav_password_security')} + {t('nav_device_management')} diff --git a/webapp/src/components/PasswordSecurityPage.tsx b/webapp/src/components/PasswordSecurityPage.tsx new file mode 100644 index 0000000..dd37501 --- /dev/null +++ b/webapp/src/components/PasswordSecurityPage.tsx @@ -0,0 +1,169 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact'; +import { Link } from 'wouter'; +import { maskSecret } from '@/components/vault/vault-page-helpers'; +import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache'; +import { t } from '@/lib/i18n'; +import type { Cipher } from '@/lib/types'; + +interface PasswordSecurityPageProps { + ciphers: Cipher[]; + loading: boolean; +} + +type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all'; + +function vaultFingerprint(ciphers: Cipher[]): string { + return JSON.stringify(ciphers.map((cipher) => ({ + id: cipher.id, + type: cipher.type, + revisionDate: cipher.revisionDate || '', + deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '', + }))); +} + +function formatCheckedAt(value: number): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value); +} + +export default function PasswordSecurityPage(props: PasswordSecurityPageProps) { + const fingerprint = vaultFingerprint(props.ciphers); + const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint)); + const [filter, setFilter] = useState('all'); + const [revealedPasswordIds, setRevealedPasswordIds] = useState>(() => new Set()); + useEffect(() => { + setSecurityState(getPasswordSecurityState(fingerprint)); + setFilter('all'); + setRevealedPasswordIds(new Set()); + return subscribePasswordSecurityState(() => { + const next = readPasswordSecurityState(fingerprint); + if (next) setSecurityState(next); + }); + }, [fingerprint]); + + const { report, scannedAt, scanning, progress, scanError } = securityState; + + const eligibleCount = useMemo( + () => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length, + [props.ciphers], + ); + const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]); + const filteredItems = useMemo(() => { + if (!report || filter === 'all') return report?.items || []; + if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0); + if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1); + return report.items.filter((item) => item.weak); + }, [filter, report]); + const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId)); + + const togglePasswordVisibility = (cipherId: string) => { + setRevealedPasswordIds((current) => { + const next = new Set(current); + if (next.has(cipherId)) next.delete(cipherId); + else next.add(cipherId); + return next; + }); + }; + + const toggleAllPasswordVisibility = () => { + if (!report) return; + setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId))); + }; + + const scan = () => { + setRevealedPasswordIds(new Set()); + setFilter('all'); + startPasswordSecurityScan(fingerprint, props.ciphers); + }; + + return ( +
+
+
+
+

{t('txt_password_security')}

+

{t('txt_password_security_privacy')}

+ {scannedAt &&

{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}

} +
+
+ {report && } + +
+
+ + {!report && !scanning && !props.loading && ( +
+
+ )} + + {(scanning || report) && ( +
+ } tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} /> + } tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} /> + } tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} /> + } tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} /> +
+ )} + + {scanError &&
{t('txt_password_security_check_failed')}
} + + {report && ( +
+ {report.unavailableCount > 0 && ( +
{t('txt_password_security_unavailable', { count: report.unavailableCount })}
+ )} + {!report.items.length ? ( +
{t('txt_no_password_risks')}
+ ) : !filteredItems.length ? ( +
{t('txt_no_password_risks_in_filter')}
+ ) : ( +
+ {filteredItems.map((item) => { + const cipher = ciphersById.get(item.cipherId); + const name = String(cipher?.decName || cipher?.name || ''); + const password = String(cipher?.login?.decPassword || ''); + const passwordVisible = revealedPasswordIds.has(item.cipherId); + return
+
+
+ {name || t('txt_no_name')} +
+ {item.exposedCount === null && {t('txt_password_security_not_checked')}} + {(item.exposedCount || 0) > 0 && {t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}} + {item.weak && {t('txt_password_security_weak_short')}} + {item.reusedCount > 1 && {t('txt_password_security_reused_short')}} +
+
+ {passwordVisible ? password : maskSecret(password)} +
+
+ + + {t('txt_password_security_jump')} + +
+
; + })} +
+ )} +
+ )} +
+ ); +} + +function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) { + return ; +} diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index b3e53df..88fd844 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -87,6 +87,7 @@ export default function VaultPage(props: VaultPageProps) { const [sidebarFilter, setSidebarFilter] = useState({ kind: 'all' }); const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedMap, setSelectedMap] = useState>({}); + const pendingFocusCipherIdRef = useRef(null); const [showPassword, setShowPassword] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -497,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) { if (sidebarFilter.kind === 'duplicates') setSelectedMap({}); }, [sidebarFilter.kind, duplicateMode]); + useEffect(() => { + if (typeof window === 'undefined') return; + const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim(); + if (!focusId) return; + pendingFocusCipherIdRef.current = focusId; + }, []); + + useEffect(() => { + const focusId = pendingFocusCipherIdRef.current; + if (!focusId) return; + const cipher = cipherById.get(focusId); + if (!cipher) { + if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null; + return; + } + + const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher) + ? { kind: 'trash' } + : isCipherVisibleInArchive(cipher) + ? { kind: 'archive' } + : { kind: 'all' }; + setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter)); + setSearchInput(''); + setSearchQuery(''); + setIsEditing(false); + setIsCreating(false); + setDraft(null); + }, [cipherById, props.ciphers.length, props.loading]); + useEffect(() => { if (isCreating) return; + + const focusId = pendingFocusCipherIdRef.current; + if (focusId) { + if (!filteredCipherIds.has(focusId)) return; + setSelectedCipherId(focusId); + setRepromptApprovedCipherId(null); + setShowPassword(false); + setHiddenFieldVisibleMap({}); + if (isMobileLayout) setMobilePanel('detail'); + setMobileSidebarOpen(false); + pendingFocusCipherIdRef.current = null; + if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') { + const url = new URL(window.location.href); + if (url.searchParams.has('cipher')) { + url.searchParams.delete('cipher'); + const next = `${url.pathname}${url.search}${url.hash}`; + window.history.replaceState(null, '', next || '/vault'); + } + } + return; + } + if (!filteredCiphers.length) { if (selectedCipherId) setSelectedCipherId(''); return; @@ -506,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) { if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) { setSelectedCipherId(filteredCiphers[0].id); } - }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]); + }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]); const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]); const virtualRange = useMemo(() => { diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index 3415191..20d1973 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -1,8 +1,9 @@ import { createPortal } from 'preact/compat'; -import { useEffect, useMemo, useState } from 'preact/hooks'; -import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { AlertTriangle, Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RefreshCw, RotateCcw, ShieldCheck, ShieldAlert, Trash2, X } from 'lucide-preact'; import { useDialogLifecycle } from '@/components/ConfirmDialog'; import type { TotpCodeResult } from '@/lib/crypto'; +import { checkPasswordLeaked, type PasswordBreachResult } from '@/lib/password-security'; import type { Cipher } from '@/lib/types'; import { t } from '@/lib/i18n'; import { @@ -21,6 +22,10 @@ import { toBooleanFieldValue, } from '@/components/vault/vault-page-helpers'; +function isAbortError(error: unknown): boolean { + return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError'; +} + interface VaultDetailViewProps { selectedCipher: Cipher; repromptApprovedCipherId: string | null; @@ -90,6 +95,9 @@ export default function VaultDetailView(props: VaultDetailViewProps) { const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false); + const [breachResult, setBreachResult] = useState(null); + const [checkingBreach, setCheckingBreach] = useState(false); + const breachControllerRef = useRef(null); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const isDeleted = isCipherDeleted(props.selectedCipher); const passwordHistoryEntries = useMemo( @@ -103,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) { [props.selectedCipher.passwordHistory] ); useEffect(() => { + breachControllerRef.current?.abort(); + breachControllerRef.current = null; setShowSshPrivateKey(false); setPasswordHistoryOpen(false); - }, [props.selectedCipher.id]); + setBreachResult(null); + setCheckingBreach(false); + return () => { + breachControllerRef.current?.abort(); + breachControllerRef.current = null; + }; + }, [props.selectedCipher.id, props.selectedCipher.login?.decPassword]); + const checkBreach = async () => { + const password = String(props.selectedCipher.login?.decPassword || ''); + if (!password) return; + breachControllerRef.current?.abort(); + const controller = new AbortController(); + breachControllerRef.current = controller; + setCheckingBreach(true); + setBreachResult(null); + try { + const result = await checkPasswordLeaked(password, fetch, controller.signal); + if (controller.signal.aborted) return; + setBreachResult(result); + } catch (error) { + if (controller.signal.aborted || isAbortError(error)) return; + setBreachResult({ count: null, available: false }); + } finally { + if (breachControllerRef.current === controller) { + breachControllerRef.current = null; + setCheckingBreach(false); + } + } + }; const formatDownloadLabel = (attachmentId: string) => { const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); @@ -172,8 +210,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) { + + {breachResult && ( +
+ {breachResult.available ? (breachResult.count ? : ) : } + {breachResult.available ? (breachResult.count ? t('txt_password_exposed_count', { count: breachResult.count }) : t('txt_password_not_found_in_breaches')) : t('txt_password_security_check_failed')} +
+ )} {!!props.selectedCipher.login.decTotp && (
{t('txt_totp')} diff --git a/webapp/src/components/vault/VaultSidebar.tsx b/webapp/src/components/vault/VaultSidebar.tsx index 8d2a5f2..0e8d00e 100644 --- a/webapp/src/components/vault/VaultSidebar.tsx +++ b/webapp/src/components/vault/VaultSidebar.tsx @@ -17,11 +17,13 @@ import { LayoutGrid, Pencil, ShieldUser, + ShieldCheck, Star, StickyNote, Trash2, X, } from 'lucide-preact'; +import { Link } from 'wouter'; import type { Folder } from '@/lib/types'; import { t } from '@/lib/i18n'; import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers'; @@ -95,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
)}
+ + {t('nav_password_security')} + diff --git a/webapp/src/lib/i18n/locales/de.ts b/webapp/src/lib/i18n/locales/de.ts index 86dcc21..99fbcec 100644 --- a/webapp/src/lib/i18n/locales/de.ts +++ b/webapp/src/lib/i18n/locales/de.ts @@ -1448,4 +1448,12 @@ const de: Record = { "txt_ip_address": "IP-Adresse" }; +Object.assign(de, { + "nav_password_security": "Passwortsicherheit", "txt_password_security": "Passwort-Sicherheitsprüfung", "txt_password_security_privacy": "Passwörter werden lokal geprüft. Erst nach dem Start wird nur ein anonymer Hash-Präfix an die Leckdatenbank gesendet.", "txt_check_password_security": "Prüfung starten", "txt_checking_password_security": "Prüfung läuft", "txt_recheck_password_security": "Erneut prüfen", "txt_password_security_ready": "Ihr Tresor ist für eine Sicherheitsprüfung bereit.", "txt_password_security_no_login": "Es gibt keine Login-Passwörter zu prüfen.", "txt_password_security_manual": "Die Prüfung startet nur auf Ihre Anfrage. Ergebnisse bleiben nur auf dieser Seite.", "txt_password_security_no_login_help": "Fügen Sie einen Login-Eintrag mit Passwort hinzu und prüfen Sie ihn anschließend hier.", "txt_exposed_passwords": "Geleakt", "txt_reused_passwords": "Wiederverwendet", "txt_weak_passwords": "Schwach", "txt_passwords_checked": "Geprüft", "txt_password_security_unavailable": "{count} Passwortprüfungen konnten die Leckdatenbank nicht erreichen. Sie werden nicht als sicher markiert.", "txt_password_security_not_checked": "Nicht geprüft", "txt_password_exposed_count": "In {count} Lecks gefunden", "txt_password_reused_count": "{count}-mal verwendet", "txt_weak_password": "Schwaches Passwort", "txt_no_password_risks": "Keine Passwortrisiken gefunden", "txt_open_vault": "Tresor öffnen", "txt_check_password_breach": "Leck prüfen", "txt_password_not_found_in_breaches": "Nicht in der Leckdatenbank gefunden", "txt_password_security_check_failed": "Die Leckprüfung konnte nicht abgeschlossen werden." +}); + +Object.assign(de, { "txt_password_security_last_checked": "Zuletzt überprüft: {value}" }); +Object.assign(de, { "txt_no_password_risks_in_filter": "Keine Passwortrisiken in dieser Kategorie" }); +Object.assign(de, { "txt_password_security_show_all": "Alle anzeigen", "txt_password_security_hide_all": "Alle ausblenden", "txt_password_security_jump": "Öffnen", "txt_password_security_exposed_short": "{count}-mal geleakt", "txt_password_security_weak_short": "Schwaches Passwort", "txt_password_security_reused_short": "Wiederverwendet" }); + export default de; diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index 98aa102..8a927c6 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -1471,4 +1471,39 @@ const en: Record = { "txt_ip_address": "IP address" }; +Object.assign(en, { + "nav_password_security": "Password Security", + "txt_password_security": "Password Security Check", + "txt_password_security_privacy": "Passwords are checked locally in your browser. Only an anonymous hash prefix is sent to the breach database.", + "txt_check_password_security": "Start check", + "txt_checking_password_security": "Checking", + "txt_recheck_password_security": "Check again", + "txt_password_security_ready": "Your vault is ready for a security check.", + "txt_password_security_no_login": "There are no login passwords to check.", + "txt_password_security_manual": "The check only starts when you choose it. Results are kept until you refresh or your vault changes.", + "txt_password_security_no_login_help": "Add a login item with a password, then return here to check it.", + "txt_exposed_passwords": "Exposed", + "txt_reused_passwords": "Reused", + "txt_weak_passwords": "Weak", + "txt_passwords_checked": "Checked", + "txt_password_security_last_checked": "Last checked: {value}", + "txt_password_security_show_all": "Show all", + "txt_password_security_hide_all": "Hide all", + "txt_password_security_jump": "Go to item", + "txt_password_security_exposed_short": "Exposed {count} times", + "txt_password_security_weak_short": "Weak password", + "txt_password_security_reused_short": "Reused", + "txt_password_security_unavailable": "{count} password checks could not reach the breach database. They are not marked safe.", + "txt_password_security_not_checked": "Not checked", + "txt_password_exposed_count": "Found in {count} breaches", + "txt_password_reused_count": "Used {count} times", + "txt_weak_password": "Weak password", + "txt_no_password_risks": "No password risks found", + "txt_no_password_risks_in_filter": "No password risks in this category", + "txt_open_vault": "Open vault", + "txt_check_password_breach": "Check breach", + "txt_password_not_found_in_breaches": "Not found in the breach database", + "txt_password_security_check_failed": "The breach check could not be completed." +}); + export default en; diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 2aa684e..fa8f122 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -1448,4 +1448,12 @@ const es: Record = { "txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública" }; +Object.assign(es, { + "nav_password_security": "Seguridad de contraseñas", "txt_password_security": "Comprobación de seguridad", "txt_password_security_privacy": "Las contraseñas se comprueban localmente. Solo se envía un prefijo de hash anónimo a la base de filtraciones al iniciar la comprobación.", "txt_check_password_security": "Iniciar comprobación", "txt_checking_password_security": "Comprobando", "txt_recheck_password_security": "Comprobar de nuevo", "txt_password_security_ready": "Tu bóveda está lista para una comprobación de seguridad.", "txt_password_security_no_login": "No hay contraseñas de inicio de sesión para comprobar.", "txt_password_security_manual": "La comprobación solo empieza cuando la eliges. Los resultados se conservan solo en esta página.", "txt_password_security_no_login_help": "Añade un inicio de sesión con contraseña y vuelve aquí para comprobarlo.", "txt_exposed_passwords": "Filtradas", "txt_reused_passwords": "Reutilizadas", "txt_weak_passwords": "Débiles", "txt_passwords_checked": "Comprobadas", "txt_password_security_unavailable": "{count} comprobaciones no pudieron acceder a la base de filtraciones. No se marcan como seguras.", "txt_password_security_not_checked": "Sin comprobar", "txt_password_exposed_count": "Encontrada en {count} filtraciones", "txt_password_reused_count": "Usada {count} veces", "txt_weak_password": "Contraseña débil", "txt_no_password_risks": "No se encontraron riesgos de contraseña", "txt_open_vault": "Abrir bóveda", "txt_check_password_breach": "Comprobar filtración", "txt_password_not_found_in_breaches": "No encontrada en la base de filtraciones", "txt_password_security_check_failed": "No se pudo completar la comprobación de filtraciones." +}); + +Object.assign(es, { "txt_password_security_last_checked": "Última comprobación: {value}" }); +Object.assign(es, { "txt_no_password_risks_in_filter": "No hay riesgos de contraseña en esta categoría" }); +Object.assign(es, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" }); + export default es; diff --git a/webapp/src/lib/i18n/locales/fi.ts b/webapp/src/lib/i18n/locales/fi.ts index c27d97a..f561ed2 100644 --- a/webapp/src/lib/i18n/locales/fi.ts +++ b/webapp/src/lib/i18n/locales/fi.ts @@ -1448,4 +1448,12 @@ const fi: Record = { "txt_ip_address": "IP-osoite" }; +Object.assign(fi, { + "nav_password_security": "Salasanasuojaus", "txt_password_security": "Salasanojen turvatarkistus", "txt_password_security_privacy": "Salasanat tarkistetaan paikallisesti. Vain anonyymi hajautteen alku lähetetään vuototietokantaan tarkistuksen alkaessa.", "txt_check_password_security": "Aloita tarkistus", "txt_checking_password_security": "Tarkistetaan", "txt_recheck_password_security": "Tarkista uudelleen", "txt_password_security_ready": "Holvisi on valmis turvatarkistukseen.", "txt_password_security_no_login": "Tarkistettavia kirjautumissalasanoja ei ole.", "txt_password_security_manual": "Tarkistus käynnistyy vain valinnastasi. Tulokset säilyvät vain tällä sivulla.", "txt_password_security_no_login_help": "Lisää kirjautuminen salasanalla ja palaa sitten tarkistamaan se.", "txt_exposed_passwords": "Vuotaneet", "txt_reused_passwords": "Uudelleenkäytetyt", "txt_weak_passwords": "Heikot", "txt_passwords_checked": "Tarkistettu", "txt_password_security_unavailable": "{count} salasanatarkistusta ei tavoittanut vuototietokantaa. Niitä ei merkitä turvallisiksi.", "txt_password_security_not_checked": "Ei tarkistettu", "txt_password_exposed_count": "Löytyi {count} vuodosta", "txt_password_reused_count": "Käytetty {count} kertaa", "txt_weak_password": "Heikko salasana", "txt_no_password_risks": "Salasanariskejä ei löytynyt", "txt_open_vault": "Avaa holvi", "txt_check_password_breach": "Tarkista vuoto", "txt_password_not_found_in_breaches": "Ei löytynyt vuototietokannasta", "txt_password_security_check_failed": "Vuototarkistusta ei voitu suorittaa." +}); + +Object.assign(fi, { "txt_password_security_last_checked": "Tarkistettu viimeksi: {value}" }); +Object.assign(fi, { "txt_no_password_risks_in_filter": "Tässä luokassa ei ole salasanojen riskejä" }); +Object.assign(fi, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" }); + export default fi; diff --git a/webapp/src/lib/i18n/locales/fr.ts b/webapp/src/lib/i18n/locales/fr.ts index 2352119..0ced531 100644 --- a/webapp/src/lib/i18n/locales/fr.ts +++ b/webapp/src/lib/i18n/locales/fr.ts @@ -1448,4 +1448,12 @@ const fr: Record = { "txt_ip_address": "Adresse IP" }; +Object.assign(fr, { + "nav_password_security": "Sécurité des mots de passe", "txt_password_security": "Vérification de sécurité", "txt_password_security_privacy": "Les mots de passe sont vérifiés localement. Seul un préfixe de hachage anonyme est envoyé à la base de fuites après le démarrage.", "txt_check_password_security": "Lancer la vérification", "txt_checking_password_security": "Vérification", "txt_recheck_password_security": "Vérifier à nouveau", "txt_password_security_ready": "Votre coffre est prêt pour une vérification de sécurité.", "txt_password_security_no_login": "Aucun mot de passe de connexion à vérifier.", "txt_password_security_manual": "La vérification ne démarre que sur votre demande. Les résultats restent sur cette page.", "txt_password_security_no_login_help": "Ajoutez une connexion avec mot de passe, puis revenez ici pour la vérifier.", "txt_exposed_passwords": "Exposés", "txt_reused_passwords": "Réutilisés", "txt_weak_passwords": "Faibles", "txt_passwords_checked": "Vérifiés", "txt_password_security_unavailable": "{count} vérifications n'ont pas pu joindre la base de fuites. Elles ne sont pas marquées comme sûres.", "txt_password_security_not_checked": "Non vérifié", "txt_password_exposed_count": "Trouvé dans {count} fuites", "txt_password_reused_count": "Utilisé {count} fois", "txt_weak_password": "Mot de passe faible", "txt_no_password_risks": "Aucun risque de mot de passe détecté", "txt_open_vault": "Ouvrir le coffre", "txt_check_password_breach": "Vérifier la fuite", "txt_password_not_found_in_breaches": "Introuvable dans la base de fuites", "txt_password_security_check_failed": "La vérification de fuite n'a pas pu être terminée." +}); + +Object.assign(fr, { "txt_password_security_last_checked": "Dernière vérification : {value}" }); +Object.assign(fr, { "txt_no_password_risks_in_filter": "Aucun risque de mot de passe dans cette catégorie" }); +Object.assign(fr, { "txt_password_security_show_all": "Tout afficher", "txt_password_security_hide_all": "Tout masquer", "txt_password_security_jump": "Ouvrir", "txt_password_security_exposed_short": "Exposé {count} fois", "txt_password_security_weak_short": "Mot de passe faible", "txt_password_security_reused_short": "Réutilisé" }); + export default fr; diff --git a/webapp/src/lib/i18n/locales/it.ts b/webapp/src/lib/i18n/locales/it.ts index b807cf8..1f882c1 100644 --- a/webapp/src/lib/i18n/locales/it.ts +++ b/webapp/src/lib/i18n/locales/it.ts @@ -1448,4 +1448,12 @@ const it: Record = { "txt_ip_address": "Indirizzo IP" }; +Object.assign(it, { + "nav_password_security": "Sicurezza password", "txt_password_security": "Controllo sicurezza password", "txt_password_security_privacy": "Le password vengono controllate localmente. Solo un prefisso hash anonimo viene inviato al database delle violazioni dopo l'avvio.", "txt_check_password_security": "Avvia controllo", "txt_checking_password_security": "Controllo in corso", "txt_recheck_password_security": "Controlla di nuovo", "txt_password_security_ready": "Il tuo archivio è pronto per un controllo di sicurezza.", "txt_password_security_no_login": "Non ci sono password di accesso da controllare.", "txt_password_security_manual": "Il controllo parte solo quando lo scegli. I risultati restano solo in questa pagina.", "txt_password_security_no_login_help": "Aggiungi un accesso con password, quindi torna qui per controllarlo.", "txt_exposed_passwords": "Esposte", "txt_reused_passwords": "Riutilizzate", "txt_weak_passwords": "Deboli", "txt_passwords_checked": "Controllate", "txt_password_security_unavailable": "{count} controlli non hanno raggiunto il database delle violazioni. Non sono contrassegnati come sicuri.", "txt_password_security_not_checked": "Non controllata", "txt_password_exposed_count": "Trovata in {count} violazioni", "txt_password_reused_count": "Usata {count} volte", "txt_weak_password": "Password debole", "txt_no_password_risks": "Nessun rischio password trovato", "txt_open_vault": "Apri archivio", "txt_check_password_breach": "Controlla violazione", "txt_password_not_found_in_breaches": "Non trovata nel database delle violazioni", "txt_password_security_check_failed": "Impossibile completare il controllo delle violazioni." +}); + +Object.assign(it, { "txt_password_security_last_checked": "Ultimo controllo: {value}" }); +Object.assign(it, { "txt_no_password_risks_in_filter": "Nessun rischio password in questa categoria" }); +Object.assign(it, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" }); + export default it; diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index 973a7c0..8a97d4b 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -1448,4 +1448,12 @@ const ru: Record = { "txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ" }; +Object.assign(ru, { + "nav_password_security": "Безопасность паролей", "txt_password_security": "Проверка безопасности паролей", "txt_password_security_privacy": "Пароли проверяются локально. После запуска в базу утечек передаётся только анонимный префикс хеша.", "txt_check_password_security": "Начать проверку", "txt_checking_password_security": "Проверка", "txt_recheck_password_security": "Проверить снова", "txt_password_security_ready": "Ваше хранилище готово к проверке безопасности.", "txt_password_security_no_login": "Нет паролей для входа, доступных для проверки.", "txt_password_security_manual": "Проверка запускается только по вашему выбору. Результаты остаются только на этой странице.", "txt_password_security_no_login_help": "Добавьте запись входа с паролем и вернитесь сюда для проверки.", "txt_exposed_passwords": "Скомпрометированы", "txt_reused_passwords": "Повторно используются", "txt_weak_passwords": "Слабые", "txt_passwords_checked": "Проверено", "txt_password_security_unavailable": "{count} проверок не смогли обратиться к базе утечек. Они не помечены безопасными.", "txt_password_security_not_checked": "Не проверено", "txt_password_exposed_count": "Найдено в {count} утечках", "txt_password_reused_count": "Используется {count} раз", "txt_weak_password": "Слабый пароль", "txt_no_password_risks": "Рисков паролей не найдено", "txt_open_vault": "Открыть хранилище", "txt_check_password_breach": "Проверить утечку", "txt_password_not_found_in_breaches": "Не найден в базе утечек", "txt_password_security_check_failed": "Не удалось завершить проверку утечки." +}); + +Object.assign(ru, { "txt_password_security_last_checked": "Последняя проверка: {value}" }); +Object.assign(ru, { "txt_no_password_risks_in_filter": "В этой категории нет рисков для паролей" }); +Object.assign(ru, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" }); + export default ru; diff --git a/webapp/src/lib/i18n/locales/sv.ts b/webapp/src/lib/i18n/locales/sv.ts index 2d9c72a..9baf63d 100644 --- a/webapp/src/lib/i18n/locales/sv.ts +++ b/webapp/src/lib/i18n/locales/sv.ts @@ -1448,4 +1448,12 @@ const sv: Record = { "txt_ip_address": "IP-adress" }; +Object.assign(sv, { + "nav_password_security": "Lösenordssäkerhet", "txt_password_security": "Säkerhetskontroll för lösenord", "txt_password_security_privacy": "Lösenord kontrolleras lokalt. Endast ett anonymt hashprefix skickas till läckdatabasen när du startar kontrollen.", "txt_check_password_security": "Starta kontroll", "txt_checking_password_security": "Kontrollerar", "txt_recheck_password_security": "Kontrollera igen", "txt_password_security_ready": "Ditt valv är redo för en säkerhetskontroll.", "txt_password_security_no_login": "Det finns inga inloggningslösenord att kontrollera.", "txt_password_security_manual": "Kontrollen startar bara när du väljer den. Resultaten stannar på denna sida.", "txt_password_security_no_login_help": "Lägg till en inloggning med lösenord och återvänd sedan hit för att kontrollera den.", "txt_exposed_passwords": "Läckta", "txt_reused_passwords": "Återanvända", "txt_weak_passwords": "Svaga", "txt_passwords_checked": "Kontrollerade", "txt_password_security_unavailable": "{count} lösenordskontroller kunde inte nå läckdatabasen. De markeras inte som säkra.", "txt_password_security_not_checked": "Inte kontrollerad", "txt_password_exposed_count": "Hittades i {count} läckor", "txt_password_reused_count": "Användes {count} gånger", "txt_weak_password": "Svagt lösenord", "txt_no_password_risks": "Inga lösenordsrisker hittades", "txt_open_vault": "Öppna valv", "txt_check_password_breach": "Kontrollera läcka", "txt_password_not_found_in_breaches": "Hittades inte i läckdatabasen", "txt_password_security_check_failed": "Läckkontrollen kunde inte slutföras." +}); + +Object.assign(sv, { "txt_password_security_last_checked": "Senast kontrollerad: {value}" }); +Object.assign(sv, { "txt_no_password_risks_in_filter": "Inga lösenordsrisker i denna kategori" }); +Object.assign(sv, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" }); + export default sv; diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index 7836132..90ed5ed 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -1451,4 +1451,39 @@ const zhCN: Record = { "txt_ip_address": "IP 地址" }; +Object.assign(zhCN, { + "nav_password_security": "安全检测", + "txt_password_security": "安全检测", + "txt_password_security_privacy": "密码仅在本地前端检查;只有匿名哈希前缀会发送到泄露密码库。", + "txt_check_password_security": "开始检查", + "txt_checking_password_security": "检查中", + "txt_recheck_password_security": "重新检查", + "txt_password_security_ready": "密码库已准备好进行安全检查。", + "txt_password_security_no_login": "没有可检查的登录密码。", + "txt_password_security_manual": "仅在您主动开始后才会联网检查;结果会保留到刷新页面或密码库内容变更前。", + "txt_password_security_no_login_help": "添加一个包含密码的登录项目后,再回到此处检查。", + "txt_exposed_passwords": "已泄露", + "txt_reused_passwords": "重复使用", + "txt_weak_passwords": "较弱", + "txt_passwords_checked": "已检查", + "txt_password_security_last_checked": "上次检测:{value}", + "txt_password_security_show_all": "显示全部", + "txt_password_security_hide_all": "隐藏全部", + "txt_password_security_jump": "跳转", + "txt_password_security_exposed_short": "泄露 {count} 次", + "txt_password_security_weak_short": "弱密码", + "txt_password_security_reused_short": "重复", + "txt_password_security_unavailable": "有 {count} 个密码无法连接泄露库,未被标记为安全。", + "txt_password_security_not_checked": "未检查", + "txt_password_exposed_count": "已在 {count} 次泄露中出现", + "txt_password_reused_count": "使用了 {count} 次", + "txt_weak_password": "较弱密码", + "txt_no_password_risks": "未发现密码风险", + "txt_no_password_risks_in_filter": "此类别中没有密码风险", + "txt_open_vault": "打开密码库", + "txt_check_password_breach": "检查泄露", + "txt_password_not_found_in_breaches": "未在泄露密码库中发现", + "txt_password_security_check_failed": "无法完成泄露检查。" +}); + export default zhCN; diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index 705d527..7a689fc 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -1451,4 +1451,39 @@ const zhTW: Record = { "txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰" }; +Object.assign(zhTW, { + "nav_password_security": "密碼安全", + "txt_password_security": "密碼安全檢查", + "txt_password_security_privacy": "密碼僅在本機前端檢查;只有匿名雜湊前綴會傳送到外洩密碼庫。", + "txt_check_password_security": "開始檢查", + "txt_checking_password_security": "檢查中", + "txt_recheck_password_security": "重新檢查", + "txt_password_security_ready": "密碼庫已準備好進行安全檢查。", + "txt_password_security_no_login": "沒有可檢查的登入密碼。", + "txt_password_security_manual": "僅在您主動開始後才會連線檢查;結果會保留到重新整理頁面或密碼庫內容變更前。", + "txt_password_security_no_login_help": "新增一個含有密碼的登入項目後,再回到此處檢查。", + "txt_exposed_passwords": "已外洩", + "txt_reused_passwords": "重複使用", + "txt_weak_passwords": "較弱", + "txt_passwords_checked": "已檢查", + "txt_password_security_last_checked": "上次檢查:{value}", + "txt_password_security_show_all": "顯示全部", + "txt_password_security_hide_all": "隱藏全部", + "txt_password_security_jump": "跳轉", + "txt_password_security_exposed_short": "外洩 {count} 次", + "txt_password_security_weak_short": "弱密碼", + "txt_password_security_reused_short": "重複", + "txt_password_security_unavailable": "有 {count} 個密碼無法連線至外洩資料庫,未被標記為安全。", + "txt_password_security_not_checked": "未檢查", + "txt_password_exposed_count": "已在 {count} 次外洩中出現", + "txt_password_reused_count": "使用了 {count} 次", + "txt_weak_password": "較弱密碼", + "txt_no_password_risks": "未發現密碼風險", + "txt_no_password_risks_in_filter": "此類別中沒有密碼風險", + "txt_open_vault": "開啟密碼庫", + "txt_check_password_breach": "檢查外洩", + "txt_password_not_found_in_breaches": "未在外洩密碼庫中發現", + "txt_password_security_check_failed": "無法完成外洩檢查。" +}); + export default zhTW; diff --git a/webapp/src/lib/password-security-cache.ts b/webapp/src/lib/password-security-cache.ts new file mode 100644 index 0000000..7c74c31 --- /dev/null +++ b/webapp/src/lib/password-security-cache.ts @@ -0,0 +1,74 @@ +import { inspectVaultPasswordSecurity, type PasswordSecurityReport } from '@/lib/password-security'; +import type { Cipher } from '@/lib/types'; + +export interface PasswordSecurityState { + fingerprint: string; + report: PasswordSecurityReport | null; + scannedAt: number | null; + scanning: boolean; + progress: { checked: number; total: number }; + scanError: boolean; +} + +type InternalPasswordSecurityState = PasswordSecurityState & { controller: AbortController | null }; + +let state: InternalPasswordSecurityState | null = null; +const listeners = new Set<() => void>(); + +function notify(): void { + listeners.forEach((listener) => listener()); +} + +function createState(fingerprint: string): InternalPasswordSecurityState { + return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null }; +} + +export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState { + if (state?.fingerprint !== fingerprint) { + state?.controller?.abort(); + state = createState(fingerprint); + } + return state; +} + +export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null { + return state?.fingerprint === fingerprint ? state : null; +} + +export function subscribePasswordSecurityState(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void { + const current = getPasswordSecurityState(fingerprint); + current.controller?.abort(); + const controller = new AbortController(); + const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length; + state = { ...current, report: null, scannedAt: null, scanning: true, progress: { checked: 0, total }, scanError: false, controller }; + notify(); + + void (async () => { + try { + const report = await inspectVaultPasswordSecurity(ciphers, (checked, total) => { + if (controller.signal.aborted || state?.controller !== controller) return; + state = { ...state, progress: { checked, total } }; + notify(); + }, fetch, controller.signal); + if (controller.signal.aborted || state?.controller !== controller) return; + state = { ...state, report, scannedAt: Date.now() }; + } catch (error) { + if (controller.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return; + if (state?.controller === controller) state = { ...state, scanError: true }; + } finally { + if (state?.controller === controller) state = { ...state, controller: null, scanning: false }; + notify(); + } + })(); +} + +export function clearPasswordSecurityCache(): void { + state?.controller?.abort(); + state = null; + notify(); +} diff --git a/webapp/src/lib/password-security.ts b/webapp/src/lib/password-security.ts new file mode 100644 index 0000000..abb949e --- /dev/null +++ b/webapp/src/lib/password-security.ts @@ -0,0 +1,229 @@ +import type { Cipher } from '@/lib/types'; + +const PWNED_PASSWORDS_RANGE_URL = 'https://api.pwnedpasswords.com/range/'; +const MAX_CONCURRENT_BREACH_CHECKS = 5; +const COMMON_PASSWORDS = new Set([ + 'password', 'password1', '123456', '12345678', '123456789', 'qwerty', 'abc123', 'letmein', 'welcome', 'iloveyou', 'admin', 'changeme', +]); + +export interface PasswordBreachResult { + count: number | null; + available: boolean; +} + +export interface PasswordSecurityItem { + cipherId: string; + exposedCount: number | null; + reusedCount: number; + weak: boolean; +} + +export interface PasswordSecurityReport { + eligibleCount: number; + checkedCount: number; + exposedCount: number; + reusedCount: number; + weakCount: number; + unavailableCount: number; + items: PasswordSecurityItem[]; +} + +type Candidate = { + cipherId: string; + name: string; + hash: string; + weak: boolean; +}; + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('').toUpperCase(); +} + +function isAbortError(error: unknown): boolean { + return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError'; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + const error = new Error('The operation was aborted.'); + error.name = 'AbortError'; + throw error; + } +} + +export async function sha1Password(password: string): Promise { + const input = new TextEncoder().encode(password); + return bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-1', input))); +} + +function parseRangeResponse(text: string, suffix: string): number { + for (const line of text.split(/\r?\n/)) { + const separator = line.indexOf(':'); + if (separator !== 35) continue; + if (line.slice(0, separator).toUpperCase() !== suffix) continue; + const count = Number.parseInt(line.slice(separator + 1), 10); + return Number.isSafeInteger(count) && count > 0 ? count : 0; + } + return 0; +} + +export async function checkPasswordHashLeaked( + hash: string, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, +): Promise { + if (!/^[A-F0-9]{40}$/.test(hash)) throw new Error('Password hash is invalid.'); + throwIfAborted(signal); + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), 12_000); + const onExternalAbort = () => controller.abort(); + signal?.addEventListener('abort', onExternalAbort, { once: true }); + if (signal?.aborted) controller.abort(); + try { + const response = await fetchImpl(`${PWNED_PASSWORDS_RANGE_URL}${hash.slice(0, 5)}`, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + cache: 'no-store', + referrerPolicy: 'no-referrer', + headers: { 'Add-Padding': 'true' }, + signal: controller.signal, + }); + if (!response.ok) throw new Error(`Pwned Passwords returned ${response.status}.`); + return parseRangeResponse(await response.text(), hash.slice(5)); + } catch (error) { + // External cancel (leave page / re-scan) must stay distinguishable from timeout/network failures. + if (signal?.aborted) { + const abortError = new Error('The operation was aborted.'); + abortError.name = 'AbortError'; + throw abortError; + } + if (isAbortError(error)) throw new Error('Pwned Passwords request timed out.'); + throw error; + } finally { + globalThis.clearTimeout(timeout); + signal?.removeEventListener('abort', onExternalAbort); + } +} + +export async function checkPasswordLeaked( + password: string, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, +): Promise { + if (!password) return { count: 0, available: true }; + try { + return { count: await checkPasswordHashLeaked(await sha1Password(password), fetchImpl, signal), available: true }; + } catch (error) { + if (isAbortError(error) || signal?.aborted) throw error; + return { count: null, available: false }; + } +} + +function hasSimpleSequence(value: string): boolean { + const normalized = value.toLowerCase(); + return ['0123456789', '9876543210', 'abcdefghijklmnopqrstuvwxyz', 'zyxwvutsrqponmlkjihgfedcba', 'qwertyuiop', 'poiuytrewq'] + .some((sequence) => sequence.includes(normalized) || normalized.includes(sequence.slice(0, 5))); +} + +export function isWeakPassword(password: string, username: string = ''): boolean { + const normalized = password.toLowerCase(); + const compactUsername = username.split('@')[0]?.trim().toLowerCase() || ''; + if (COMMON_PASSWORDS.has(normalized) || password.length < 10) return true; + if (/^(.)\1+$/.test(password) || hasSimpleSequence(password)) return true; + if (compactUsername.length >= 3 && normalized.includes(compactUsername)) return true; + const classes = [/[a-z]/.test(password), /[A-Z]/.test(password), /\d/.test(password), /[^A-Za-z0-9]/.test(password)].filter(Boolean).length; + return password.length < 14 && classes < 3; +} + +function isEligibleCipher(cipher: Cipher): boolean { + return Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword; +} + +async function mapWithConcurrency( + values: T[], + limit: number, + worker: (value: T) => Promise, + signal?: AbortSignal, +): Promise { + const results = new Array(values.length); + let nextIndex = 0; + const run = async () => { + while (true) { + throwIfAborted(signal); + const index = nextIndex; + nextIndex += 1; + if (index >= values.length) return; + results[index] = await worker(values[index]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run)); + return results; +} + +export async function inspectVaultPasswordSecurity( + ciphers: Cipher[], + onProgress?: (checked: number, total: number) => void, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal); + const eligible = ciphers.filter(isEligibleCipher); + const candidates: Candidate[] = await Promise.all(eligible.map(async (cipher) => { + throwIfAborted(signal); + const password = String(cipher.login?.decPassword || ''); + const username = String(cipher.login?.decUsername || ''); + return { + cipherId: cipher.id, + name: String(cipher.decName || cipher.name || ''), + hash: await sha1Password(password), + weak: isWeakPassword(password, username), + }; + })); + const candidatesByHash = new Map(); + for (const candidate of candidates) { + const group = candidatesByHash.get(candidate.hash) || []; + group.push(candidate); + candidatesByHash.set(candidate.hash, group); + } + + const exposureByHash = new Map(); + let checked = 0; + await mapWithConcurrency([...candidatesByHash.keys()], MAX_CONCURRENT_BREACH_CHECKS, async (hash) => { + throwIfAborted(signal); + let result: PasswordBreachResult; + try { + result = { count: await checkPasswordHashLeaked(hash, fetchImpl, signal), available: true }; + } catch (error) { + if (isAbortError(error) || signal?.aborted) throw error; + result = { count: null, available: false }; + } + exposureByHash.set(hash, result); + checked += candidatesByHash.get(hash)?.length || 0; + onProgress?.(Math.min(checked, candidates.length), candidates.length); + return result; + }, signal); + + throwIfAborted(signal); + + const items = candidates.map((candidate) => { + const exposure = exposureByHash.get(candidate.hash) || { count: null, available: false }; + return { + cipherId: candidate.cipherId, + exposedCount: exposure.count, + reusedCount: candidatesByHash.get(candidate.hash)?.length || 1, + weak: candidate.weak, + }; + }).filter((item) => item.exposedCount === null || (item.exposedCount || 0) > 0 || item.reusedCount > 1 || item.weak) + .sort((a, b) => (Number(b.exposedCount || 0) - Number(a.exposedCount || 0)) || (b.reusedCount - a.reusedCount) || Number(b.weak) - Number(a.weak) || a.cipherId.localeCompare(b.cipherId)); + + return { + eligibleCount: candidates.length, + checkedCount: checked, + exposedCount: candidates.filter((candidate) => (exposureByHash.get(candidate.hash)?.count || 0) > 0).length, + reusedCount: candidates.filter((candidate) => (candidatesByHash.get(candidate.hash)?.length || 0) > 1).length, + weakCount: candidates.filter((candidate) => candidate.weak).length, + unavailableCount: candidates.filter((candidate) => exposureByHash.get(candidate.hash)?.count === null).length, + items, + }; +} diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 5036379..b7cdcfb 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -3,6 +3,7 @@ @import './styles/auth.css'; @import './styles/forms.css'; @import './styles/generator.css'; +@import './styles/password-security.css'; @import './styles/shell.css'; @import './styles/vault.css'; @import './styles/management.css'; @@ -429,7 +430,7 @@ h4 { min-height: min(640px, calc(100dvh - 180px)); display: flex; flex-direction: column; - gap: 18px; + gap: 10px; } .settings-home-section { @@ -676,7 +677,7 @@ h4 { } .card { - margin-bottom: 8px; + margin-bottom: 0px; padding: 14px; } diff --git a/webapp/src/styles/password-security.css b/webapp/src/styles/password-security.css new file mode 100644 index 0000000..19f95a6 --- /dev/null +++ b/webapp/src/styles/password-security.css @@ -0,0 +1,77 @@ +.password-security-page { width: min(100%, 1180px); margin: 0; display: grid; gap: 10px; padding: 4px 0 24px; } +.password-security-intro { display: flex; align-items: center; gap: 12px; padding: 14px 16px; } +.password-security-intro-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 14px; color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); } +.password-security-intro h2 { margin: 0 0 3px; font-size: 18px; } +.password-security-intro p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; } +.password-security-intro .password-security-checked-at { margin-top: 4px; font-size: 12px; font-variant-numeric: tabular-nums; } +.password-security-intro-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } +.password-security-scan, .password-security-toggle-all { min-height: 40px; } +.password-security-empty { min-height: 190px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: var(--muted); padding: 28px; } +.password-security-empty > svg { color: var(--primary); } +.password-security-empty strong { color: var(--ink); } +.password-security-empty span { font-size: 14px; max-width: 520px; line-height: 1.5; } +.password-security-empty.compact { min-height: 150px; } +.password-security-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.password-security-metric { display: flex; align-items: center; gap: 10px; min-height: 72px; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); box-shadow: var(--shadow-sm); color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; } +.password-security-metric:hover:not(:disabled), .password-security-metric[aria-pressed='true'] { border-color: var(--primary); box-shadow: var(--shadow-md); } +.password-security-metric:active:not(:disabled) { transform: scale(.99); } +.password-security-metric:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 45%, transparent); outline-offset: 2px; } +.password-security-metric:disabled { cursor: default; } +.password-security-metric > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; } +.password-security-metric.danger > span { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, var(--panel)); } +.password-security-metric.warning > span { color: #b45309; background: #fff7e6; } +.password-security-metric.primary > span { color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); } +.password-security-metric div { display: grid; gap: 1px; min-width: 0; } +.password-security-metric strong { font-size: 20px; line-height: 1.15; font-variant-numeric: tabular-nums; } +.password-security-metric small { color: var(--muted); font-size: 12px; } +.password-security-results { padding: 8px; } +.password-security-notice { display: flex; align-items: center; gap: 8px; padding: 9px 10px; margin-bottom: 8px; border-radius: var(--radius-md); font-size: 13px; } +.password-security-notice.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; } +.password-security-list { display: grid; } +.password-security-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 64px; padding: 10px; border-bottom: 1px solid var(--line-soft); } +.password-security-item:last-child { border-bottom: 0; } +.password-security-item-main { min-width: 0; display: grid; gap: 5px; } +.password-security-item-header { display: flex; align-items: center; gap: 8px; min-width: 0; } +.password-security-item-header > strong { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.password-security-password { min-width: 0; color: var(--muted); font-size: 13px; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.password-security-item-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; } +.password-security-badges { display: flex; flex-wrap: wrap; gap: 6px; } +.risk-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 7px; border-radius: 999px; font-size: 12px; font-weight: 600; } +.risk-badge.danger { color: #b42318; background: #fef0ef; } +.risk-badge.reused { color: #92400e; background: #fff7e6; } +.risk-badge.weak { color: #5b21b6; background: #f3e8ff; } +.risk-badge.muted { color: var(--muted); background: var(--panel-soft); } +.password-security-open { flex: 0 0 auto; } +.password-breach-inline { display: flex; align-items: center; gap: 7px; margin-top: 9px; padding: 9px 10px; border-radius: var(--radius-md); font-size: 13px; line-height: 1.35; } +.password-breach-inline.safe { color: #16704d; background: #ecfdf3; border: 1px solid #b7ebcd; } +.password-breach-inline.danger { color: #b42318; background: #fef0ef; border: 1px solid #fecdc9; } +.password-breach-inline.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; } +.spin { animation: password-security-spin 900ms linear infinite; } +@keyframes password-security-spin { to { transform: rotate(360deg); } } + +@media (max-width: 760px) { + .password-security-page { width: 100%; padding: 0 0 18px; gap: 10px; } + .password-security-intro { align-items: flex-start; padding: 14px; } + .password-security-intro-icon { width: 38px; height: 38px; border-radius: 12px; } + .password-security-intro h2 { font-size: 16px; } + .password-security-intro p { font-size: 13px; } + .password-security-intro-actions { width: 100%; margin: 8px 0 0; grid-column: 1 / -1; } + .password-security-scan, .password-security-toggle-all { flex: 1 1 0; } + .password-security-intro { display: grid; grid-template-columns: auto minmax(0, 1fr); } + .password-security-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + .password-security-metric { min-height: 74px; padding: 12px; gap: 8px; } + .password-security-metric > span { width: 32px; height: 32px; border-radius: 10px; } + .password-security-metric strong { font-size: 18px; } + .password-security-item { align-items: stretch; flex-direction: column; gap: 8px; } + .password-security-item-actions { width: 100%; } + .password-security-item-actions > * { flex: 1 1 0; min-height: 40px; } +} + +:root[data-theme='dark'] .password-security-metric.warning > span, +:root[data-theme='dark'] .password-security-notice.warning, +:root[data-theme='dark'] .risk-badge.reused, +:root[data-theme='dark'] .password-breach-inline.warning { color: #fbbf24; background: rgba(180, 83, 9, .18); border-color: rgba(251, 191, 36, .25); } +:root[data-theme='dark'] .risk-badge.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); } +:root[data-theme='dark'] .risk-badge.weak { color: #d8b4fe; background: rgba(91, 33, 182, .22); } +:root[data-theme='dark'] .password-breach-inline.safe { color: #6ee7b7; background: rgba(22, 112, 77, .2); border-color: rgba(110, 231, 183, .25); } +:root[data-theme='dark'] .password-breach-inline.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); border-color: rgba(252, 165, 165, .25); }