feat(vault): add password exposure check and related UI enhancements

This commit is contained in:
shuaiplus
2026-03-08 19:23:24 +08:00
parent a372b99fc9
commit c8194a04c7
5 changed files with 334 additions and 5 deletions
+149 -3
View File
@@ -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<void>;
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error', text: string) => void;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
@@ -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<string> {
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<string, boolean>, ignoredMap: Record<string, boolean>): number {
let count = 0;
for (const [cipherId, exposed] of Object.entries(results)) {
if (exposed && !ignoredMap[cipherId]) count++;
}
return count;
}
function readIgnoredExposedMap(): Record<string, boolean> {
try {
const raw = localStorage.getItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, boolean>;
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<VaultSortMode>('edited');
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [exposedStatusMap, setExposedStatusMap] = useState<Record<string, boolean>>({});
const [ignoredExposedMap, setIgnoredExposedMap] = useState<Record<string, boolean>>(() => readIgnoredExposedMap());
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -408,6 +444,11 @@ export default function VaultPage(props: VaultPageProps) {
const attachmentInputRef = useRef<HTMLInputElement | null>(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<void> {
if (!selectedCipher) return;
if (!repromptPassword) {
@@ -927,6 +1048,9 @@ function folderName(id: string | null | undefined): string {
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'favorite' })}>
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'exposed' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'exposed' })}>
<ShieldAlert size={14} className="tree-icon" /> <span className="tree-label">{t('txt_exposed_passwords')}</span>
</button>
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
</button>
@@ -1126,7 +1250,10 @@ function folderName(id: string | null | undefined): string {
<VaultListIcon cipher={cipher} />
</div>
<div className="list-text">
<span className="list-title" title={cipher.decName || t('txt_no_name')}>{cipher.decName || t('txt_no_name')}</span>
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
{isVisibleExposed(cipher.id) ? <span className="list-badge danger">{t('txt_exposed_short')}</span> : null}
</span>
<span className="list-sub" title={listSubtitle(cipher)}>{listSubtitle(cipher)}</span>
</div>
</button>
@@ -1551,6 +1678,25 @@ function folderName(id: string | null | undefined): string {
</button>
</div>
</div>
{selectedCipherExposed && (
<div className="kv-row">
<span className="kv-label">{t('txt_exposed_passwords')}</span>
<div className="kv-main">
<strong className="exposed-status danger">
{selectedCipherIgnored ? t('txt_exposed_ignored') : t('txt_exposed')}
</strong>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
onClick={() => toggleIgnoreExposed(selectedCipher.id)}
>
<X size={14} className="btn-icon" /> {selectedCipherIgnored ? t('txt_unignore') : t('txt_ignore')}
</button>
</div>
</div>
)}
{!!selectedCipher.login.decTotp && (
<div className="kv-row">
<span className="kv-label">{t('txt_totp')}</span>