From c8194a04c7c788b2314e4eb96ed1284137f9329e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 8 Mar 2026 19:23:24 +0800 Subject: [PATCH] feat(vault): add password exposure check and related UI enhancements --- webapp/src/components/VaultPage.tsx | 152 +++++++++++++++++++++++++++- webapp/src/lib/i18n.ts | 16 +++ webapp/src/lib/password-breach.ts | 127 +++++++++++++++++++++++ webapp/src/lib/types.ts | 2 +- webapp/src/styles.css | 42 +++++++- 5 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 webapp/src/lib/password-breach.ts diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 4935e3d..779f058 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import { calcTotpNow } from '@/lib/crypto'; +import { checkCipherPasswordsExposed } from '@/lib/password-breach'; import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh'; import { ArrowUpDown, @@ -25,6 +26,7 @@ import { Pencil, Plus, RefreshCw, + ShieldAlert, ShieldUser, Star, StarOff, @@ -48,7 +50,7 @@ interface VaultPageProps { onBulkDelete: (ids: string[]) => Promise; onBulkMove: (ids: string[], folderId: string | null) => Promise; onVerifyMasterPassword: (email: string, password: string) => Promise; - onNotify: (type: 'success' | 'error', text: string) => void; + onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onCreateFolder: (name: string) => Promise; onDeleteFolder: (folderId: string) => Promise; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise; @@ -59,6 +61,7 @@ type VaultSortMode = 'edited' | 'created' | 'name'; type SidebarFilter = | { kind: 'all' } | { kind: 'favorite' } + | { kind: 'exposed' } | { kind: 'trash' } | { kind: 'type'; value: TypeFilter } | { kind: 'folder'; folderId: string | null }; @@ -77,6 +80,8 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [ ]; const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1'; +const VAULT_EXPOSED_IGNORED_STORAGE_KEY = 'nodewarden.vault.exposed-ignored.v1'; +const VAULT_EXPOSED_SIGNATURE_STORAGE_KEY = 'nodewarden.vault.exposed-signature.v1'; const MOBILE_LAYOUT_QUERY = '(max-width: 900px)'; const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ { value: 'edited', label: t('txt_sort_last_edited') }, @@ -366,12 +371,43 @@ function openUri(raw: string): void { window.open(url, '_blank', 'noopener'); } +async function computePasswordSignature(ciphers: Cipher[]): Promise { + const parts = ciphers + .filter((cipher) => Number(cipher.type || 1) === 1) + .map((cipher) => `${String(cipher.id || '').trim()}\u0000${String(cipher.login?.decPassword || '')}`) + .sort(); + const bytes = new TextEncoder().encode(parts.join('\u0001')); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function countVisibleExposed(results: Record, ignoredMap: Record): number { + let count = 0; + for (const [cipherId, exposed] of Object.entries(results)) { + if (exposed && !ignoredMap[cipherId]) count++; + } + return count; +} + +function readIgnoredExposedMap(): Record { + try { + const raw = localStorage.getItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + export default function VaultPage(props: VaultPageProps) { const [searchInput, setSearchInput] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [searchComposing, setSearchComposing] = useState(false); const [sortMode, setSortMode] = useState('edited'); const [sortMenuOpen, setSortMenuOpen] = useState(false); + const [exposedStatusMap, setExposedStatusMap] = useState>({}); + const [ignoredExposedMap, setIgnoredExposedMap] = useState>(() => readIgnoredExposedMap()); const [sidebarFilter, setSidebarFilter] = useState({ kind: 'all' }); const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedMap, setSelectedMap] = useState>({}); @@ -408,6 +444,11 @@ export default function VaultPage(props: VaultPageProps) { const attachmentInputRef = useRef(null); const sshSeedTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0); + const hasCompletedAutoExposureCheckRef = useRef(false); + + function isVisibleExposed(cipherId: string): boolean { + return !!exposedStatusMap[cipherId] && !ignoredExposedMap[cipherId]; + } useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; @@ -457,6 +498,59 @@ export default function VaultPage(props: VaultPageProps) { } }, [sortMode]); + useEffect(() => { + try { + localStorage.setItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY, JSON.stringify(ignoredExposedMap)); + } catch { + // ignore storage write failures + } + }, [ignoredExposedMap]); + + useEffect(() => { + if (props.loading) return; + + const loginCiphers = props.ciphers.filter( + (cipher) => Number(cipher.type || 1) === 1 && !!String(cipher.login?.decPassword || '').trim() + ); + + let cancelled = false; + + void (async () => { + try { + const [signature, results] = await Promise.all([ + computePasswordSignature(loginCiphers), + checkCipherPasswordsExposed(loginCiphers), + ]); + if (cancelled) return; + + setExposedStatusMap(results); + + const previousSignature = + typeof localStorage !== 'undefined' + ? String(localStorage.getItem(VAULT_EXPOSED_SIGNATURE_STORAGE_KEY) || '').trim() + : ''; + + if (typeof localStorage !== 'undefined') { + localStorage.setItem(VAULT_EXPOSED_SIGNATURE_STORAGE_KEY, signature); + } + + if (hasCompletedAutoExposureCheckRef.current && previousSignature && previousSignature !== signature) { + const count = countVisibleExposed(results, ignoredExposedMap); + if (count > 0) { + props.onNotify('warning', t('txt_exposed_password_check_complete_count', { count })); + } + } + hasCompletedAutoExposureCheckRef.current = true; + } catch { + // Keep exposed-password checks silent in the background. + } + })(); + + return () => { + cancelled = true; + }; + }, [props.ciphers, props.loading]); + useEffect(() => { const onPointerDown = (event: Event) => { if (!createMenuOpen) return; @@ -533,6 +627,7 @@ export default function VaultPage(props: VaultPageProps) { } else { if (isDeleted) return false; if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; + if (sidebarFilter.kind === 'exposed' && !isVisibleExposed(cipher.id)) return false; if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'folder') { if (sidebarFilter.folderId === null) { @@ -568,7 +663,7 @@ export default function VaultPage(props: VaultPageProps) { }); return next; - }, [props.ciphers, sidebarFilter, searchQuery, sortMode]); + }, [props.ciphers, sidebarFilter, searchQuery, sortMode, exposedStatusMap, ignoredExposedMap]); useEffect(() => { if (isCreating) return; @@ -585,6 +680,8 @@ export default function VaultPage(props: VaultPageProps) { () => props.ciphers.find((x) => x.id === selectedCipherId) || null, [props.ciphers, selectedCipherId] ); + const selectedCipherExposed = !!(selectedCipher && exposedStatusMap[selectedCipher.id]); + const selectedCipherIgnored = !!(selectedCipher && ignoredExposedMap[selectedCipher.id]); const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher); const selectedAttachments = useMemo( () => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []), @@ -788,11 +885,26 @@ function folderName(id: string | null | undefined): string { if (isCreating) { await props.onCreate(nextDraft, attachmentQueue); } else if (selectedCipher) { + const passwordChanged = + nextDraft.type === 1 && + String(nextDraft.loginPassword || '') !== String(selectedCipher.login?.decPassword || ''); const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]); await props.onUpdate(selectedCipher, nextDraft, { addFiles: attachmentQueue, removeAttachmentIds, }); + if (passwordChanged) { + setExposedStatusMap((prev) => { + const next = { ...prev }; + delete next[selectedCipher.id]; + return next; + }); + setIgnoredExposedMap((prev) => { + const next = { ...prev }; + delete next[selectedCipher.id]; + return next; + }); + } } setIsCreating(false); setIsEditing(false); @@ -859,6 +971,15 @@ function folderName(id: string | null | undefined): string { } } + function toggleIgnoreExposed(cipherId: string): void { + setIgnoredExposedMap((prev) => { + const next = { ...prev }; + if (next[cipherId]) delete next[cipherId]; + else next[cipherId] = true; + return next; + }); + } + async function verifyReprompt(): Promise { if (!selectedCipher) return; if (!repromptPassword) { @@ -927,6 +1048,9 @@ function folderName(id: string | null | undefined): string { + @@ -1126,7 +1250,10 @@ function folderName(id: string | null | undefined): string {
- {cipher.decName || t('txt_no_name')} + + {cipher.decName || t('txt_no_name')} + {isVisibleExposed(cipher.id) ? {t('txt_exposed_short')} : null} + {listSubtitle(cipher)}
@@ -1551,6 +1678,25 @@ function folderName(id: string | null | undefined): string { + {selectedCipherExposed && ( +
+ {t('txt_exposed_passwords')} +
+ + {selectedCipherIgnored ? t('txt_exposed_ignored') : t('txt_exposed')} + +
+
+ +
+
+ )} {!!selectedCipher.login.decTotp && (
{t('txt_totp')} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 26a323a..28c37c4 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -140,6 +140,11 @@ const messages: Record> = { txt_enter_master_password_to_view_this_item: "Enter master password to view this item.", txt_expiration_date: "Expiration Date", txt_expiration_days_0_never: "Expiration Days (0 = never)", + txt_exposed: "Exposed", + txt_exposed_password_check_complete_count: "{count} exposed password(s) found", + txt_exposed_ignored: "Exposed (Ignored)", + txt_exposed_passwords: "Exposed Passwords", + txt_exposed_short: "Exposed", txt_expires_at: "Expires At", txt_expires_at_value: "Expires at: {value}", txt_expiry: "Expiry", @@ -249,6 +254,7 @@ const messages: Record> = { txt_no: "No", txt_no_devices_found: "No devices found.", txt_no_folder: "No Folder", + txt_no_exposed_passwords_found: "No exposed passwords found", txt_no_items: "No items", txt_no_username: "(No username)", txt_no_verification_codes: "No verification codes", @@ -292,6 +298,7 @@ const messages: Record> = { txt_regenerate: "Regenerate", txt_registration_succeeded_please_sign_in: "Registration succeeded. Please sign in.", txt_remove: "Remove", + txt_ignore: "Ignore", txt_remove_device: "Remove device", txt_remove_device_2: "Remove Device", txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?", @@ -375,6 +382,7 @@ const messages: Record> = { txt_unlock_item: "Unlock Item", txt_unlock_send: "Unlock Send", txt_unlock_vault: "Unlock Vault", + txt_unignore: "Unignore", txt_unlocked: "Unlocked", txt_update_item_failed: "Update item failed", txt_update_send_failed: "Update send failed", @@ -433,6 +441,7 @@ const zhCNOverrides: Record = { txt_back_to_login: '返回登录', txt_unlock: '解锁', txt_unlock_vault: '解锁密码库', + txt_unignore: '取消忽略', txt_master_password: '主密码', txt_email: '邮箱', txt_name: '名称', @@ -459,6 +468,7 @@ const zhCNOverrides: Record = { txt_copy: '复制', txt_code_copied: '验证码已复制', txt_copy_link: '复制链接', + txt_ignore: '忽略', txt_select_all: '全选', txt_delete_selected: '删除所选', txt_all_items: '所有项目', @@ -467,6 +477,7 @@ const zhCNOverrides: Record = { txt_folder: '文件夹', txt_folders: '文件夹', txt_no_folder: '无文件夹', + txt_no_exposed_passwords_found: '未发现已泄露密码', txt_no_items: '没有项目', txt_no_username: '无用户名', txt_no_verification_codes: '没有验证码', @@ -474,6 +485,11 @@ const zhCNOverrides: Record = { txt_select_an_item: '请选择一个项目', txt_login: '登录', txt_card: '银行卡', + txt_exposed: '已泄露', + txt_exposed_password_check_complete_count: '发现 {count} 个已泄露密码', + txt_exposed_ignored: '已泄露(已忽略)', + txt_exposed_passwords: '是否泄露', + txt_exposed_short: '泄露', txt_identity: '身份', txt_note: '笔记', txt_secure_note: '安全笔记', diff --git a/webapp/src/lib/password-breach.ts b/webapp/src/lib/password-breach.ts new file mode 100644 index 0000000..0892fb2 --- /dev/null +++ b/webapp/src/lib/password-breach.ts @@ -0,0 +1,127 @@ +import type { Cipher } from './types'; + +const HIBP_RANGE_ENDPOINT = 'https://api.pwnedpasswords.com/range/'; +const RANGE_CACHE_PREFIX = 'nodewarden.password-breach.range.v1.'; +const RANGE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +const inMemoryRangeCache = new Map }>(); +const inflightRangeRequests = new Map>>(); + +function normalizeHashHex(value: string): string { + return String(value || '').trim().toUpperCase(); +} + +async function sha1Hex(input: string): Promise { + const bytes = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest('SHA-1', bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase(); +} + +function readCachedSuffixes(prefix: string): Set | null { + const now = Date.now(); + const memory = inMemoryRangeCache.get(prefix); + if (memory && memory.expiresAt > now) return new Set(memory.suffixes); + if (memory) inMemoryRangeCache.delete(prefix); + + if (typeof sessionStorage === 'undefined') return null; + const raw = sessionStorage.getItem(`${RANGE_CACHE_PREFIX}${prefix}`); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as { expiresAt?: number; suffixes?: string[] }; + if (!parsed.expiresAt || parsed.expiresAt <= now || !Array.isArray(parsed.suffixes)) { + sessionStorage.removeItem(`${RANGE_CACHE_PREFIX}${prefix}`); + return null; + } + const suffixes = new Set(parsed.suffixes.map(normalizeHashHex)); + inMemoryRangeCache.set(prefix, { expiresAt: parsed.expiresAt, suffixes }); + return new Set(suffixes); + } catch { + sessionStorage.removeItem(`${RANGE_CACHE_PREFIX}${prefix}`); + return null; + } +} + +function writeCachedSuffixes(prefix: string, suffixes: Set): void { + const expiresAt = Date.now() + RANGE_CACHE_TTL_MS; + inMemoryRangeCache.set(prefix, { expiresAt, suffixes: new Set(suffixes) }); + if (typeof sessionStorage === 'undefined') return; + sessionStorage.setItem( + `${RANGE_CACHE_PREFIX}${prefix}`, + JSON.stringify({ expiresAt, suffixes: Array.from(suffixes) }) + ); +} + +async function getRangeSuffixes(prefix: string): Promise> { + const normalizedPrefix = normalizeHashHex(prefix).slice(0, 5); + if (!/^[A-F0-9]{5}$/.test(normalizedPrefix)) throw new Error('Invalid password hash prefix'); + + const cached = readCachedSuffixes(normalizedPrefix); + if (cached) return cached; + + const inflight = inflightRangeRequests.get(normalizedPrefix); + if (inflight) return inflight; + + const request = (async () => { + const response = await fetch(`${HIBP_RANGE_ENDPOINT}${normalizedPrefix}`, { + method: 'GET', + headers: { + Accept: 'text/plain', + 'Add-Padding': 'true', + }, + cache: 'no-store', + }); + if (!response.ok) throw new Error('Failed to check exposed passwords'); + + const body = await response.text(); + const suffixes = new Set(); + for (const line of body.split(/\r?\n/)) { + const [suffix] = line.split(':', 1); + const normalizedSuffix = normalizeHashHex(suffix || ''); + if (/^[A-F0-9]{35}$/.test(normalizedSuffix)) suffixes.add(normalizedSuffix); + } + writeCachedSuffixes(normalizedPrefix, suffixes); + return suffixes; + })(); + + inflightRangeRequests.set(normalizedPrefix, request); + try { + return await request; + } finally { + inflightRangeRequests.delete(normalizedPrefix); + } +} + +export async function checkCipherPasswordsExposed(ciphers: Cipher[]): Promise> { + const loginCiphers = ciphers.filter((cipher) => { + const password = String(cipher.login?.decPassword || '').trim(); + return cipher.type === 1 && !!cipher.id && !!password; + }); + + const uniquePasswords = new Map(); + for (const cipher of loginCiphers) { + const password = String(cipher.login?.decPassword || ''); + if (!uniquePasswords.has(password)) { + uniquePasswords.set(password, await sha1Hex(password)); + } + } + + const prefixes = Array.from(new Set(Array.from(uniquePasswords.values(), (hash) => hash.slice(0, 5)))); + const rangeMap = new Map>(); + await Promise.all( + prefixes.map(async (prefix) => { + rangeMap.set(prefix, await getRangeSuffixes(prefix)); + }) + ); + + const results: Record = {}; + for (const cipher of loginCiphers) { + const password = String(cipher.login?.decPassword || ''); + const hash = uniquePasswords.get(password); + if (!hash) continue; + const prefix = hash.slice(0, 5); + const suffix = hash.slice(5); + results[cipher.id] = !!rangeMap.get(prefix)?.has(suffix); + } + return results; +} diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 102e367..ce6ebb9 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -278,7 +278,7 @@ export interface TokenError { export interface ToastMessage { id: string; - type: 'success' | 'error'; + type: 'success' | 'error' | 'warning'; text: string; } diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 403ac86..71e8a79 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -875,15 +875,41 @@ input[type='file'].input::file-selector-button:hover { } .list-title { - display: block; + display: flex; + align-items: center; + gap: 6px; color: #175ddc; font-size: 15px; font-weight: 700; + min-width: 0; +} + +.list-title-text { + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.list-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1; + color: #475569; + background: #e2e8f0; + flex-shrink: 0; +} + +.list-badge.danger { + color: #fff; + background: var(--danger); +} + .list-sub { display: block; color: #5f6f85; @@ -1123,6 +1149,14 @@ input[type='file'].input::file-selector-button:hover { flex-shrink: 0; } +.exposed-status { + color: #475569; +} + +.exposed-status.danger { + color: var(--danger); +} + .attachment-list { display: grid; gap: 0; @@ -1755,6 +1789,12 @@ input[type='file'].input::file-selector-button:hover { color: #9f1239; } +.toast-item.warning { + border-color: #f2b8c1; + background: #fde7eb; + color: #9f1239; +} + .toast-text { font-weight: 700; padding-right: 10px;