mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat(vault): add password exposure check and related UI enhancements
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
|
import { checkCipherPasswordsExposed } from '@/lib/password-breach';
|
||||||
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
||||||
import {
|
import {
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
ShieldAlert,
|
||||||
ShieldUser,
|
ShieldUser,
|
||||||
Star,
|
Star,
|
||||||
StarOff,
|
StarOff,
|
||||||
@@ -48,7 +50,7 @@ interface VaultPageProps {
|
|||||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||||
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
onVerifyMasterPassword: (email: string, password: string) => 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>;
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
@@ -59,6 +61,7 @@ type VaultSortMode = 'edited' | 'created' | 'name';
|
|||||||
type SidebarFilter =
|
type SidebarFilter =
|
||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { kind: 'favorite' }
|
||||||
|
| { kind: 'exposed' }
|
||||||
| { kind: 'trash' }
|
| { kind: 'trash' }
|
||||||
| { kind: 'type'; value: TypeFilter }
|
| { kind: 'type'; value: TypeFilter }
|
||||||
| { kind: 'folder'; folderId: string | null };
|
| { 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_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 MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||||
const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
@@ -366,12 +371,43 @@ function openUri(raw: string): void {
|
|||||||
window.open(url, '_blank', 'noopener');
|
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) {
|
export default function VaultPage(props: VaultPageProps) {
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchComposing, setSearchComposing] = useState(false);
|
const [searchComposing, setSearchComposing] = useState(false);
|
||||||
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
|
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
|
||||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
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 [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
@@ -408,6 +444,11 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const sshSeedTicketRef = useRef(0);
|
const sshSeedTicketRef = useRef(0);
|
||||||
const sshFingerprintTicketRef = useRef(0);
|
const sshFingerprintTicketRef = useRef(0);
|
||||||
|
const hasCompletedAutoExposureCheckRef = useRef(false);
|
||||||
|
|
||||||
|
function isVisibleExposed(cipherId: string): boolean {
|
||||||
|
return !!exposedStatusMap[cipherId] && !ignoredExposedMap[cipherId];
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||||
@@ -457,6 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}, [sortMode]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const onPointerDown = (event: Event) => {
|
const onPointerDown = (event: Event) => {
|
||||||
if (!createMenuOpen) return;
|
if (!createMenuOpen) return;
|
||||||
@@ -533,6 +627,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
} else {
|
} else {
|
||||||
if (isDeleted) return false;
|
if (isDeleted) return false;
|
||||||
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) 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 === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
|
||||||
if (sidebarFilter.kind === 'folder') {
|
if (sidebarFilter.kind === 'folder') {
|
||||||
if (sidebarFilter.folderId === null) {
|
if (sidebarFilter.folderId === null) {
|
||||||
@@ -568,7 +663,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}, [props.ciphers, sidebarFilter, searchQuery, sortMode]);
|
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, exposedStatusMap, ignoredExposedMap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
@@ -585,6 +680,8 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
|
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
|
||||||
[props.ciphers, selectedCipherId]
|
[props.ciphers, selectedCipherId]
|
||||||
);
|
);
|
||||||
|
const selectedCipherExposed = !!(selectedCipher && exposedStatusMap[selectedCipher.id]);
|
||||||
|
const selectedCipherIgnored = !!(selectedCipher && ignoredExposedMap[selectedCipher.id]);
|
||||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
||||||
const selectedAttachments = useMemo(
|
const selectedAttachments = useMemo(
|
||||||
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
||||||
@@ -788,11 +885,26 @@ function folderName(id: string | null | undefined): string {
|
|||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
await props.onCreate(nextDraft, attachmentQueue);
|
await props.onCreate(nextDraft, attachmentQueue);
|
||||||
} else if (selectedCipher) {
|
} else if (selectedCipher) {
|
||||||
|
const passwordChanged =
|
||||||
|
nextDraft.type === 1 &&
|
||||||
|
String(nextDraft.loginPassword || '') !== String(selectedCipher.login?.decPassword || '');
|
||||||
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
|
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
|
||||||
await props.onUpdate(selectedCipher, nextDraft, {
|
await props.onUpdate(selectedCipher, nextDraft, {
|
||||||
addFiles: attachmentQueue,
|
addFiles: attachmentQueue,
|
||||||
removeAttachmentIds,
|
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);
|
setIsCreating(false);
|
||||||
setIsEditing(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> {
|
async function verifyReprompt(): Promise<void> {
|
||||||
if (!selectedCipher) return;
|
if (!selectedCipher) return;
|
||||||
if (!repromptPassword) {
|
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' })}>
|
<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>
|
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
||||||
</button>
|
</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' })}>
|
<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>
|
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1126,7 +1250,10 @@ function folderName(id: string | null | undefined): string {
|
|||||||
<VaultListIcon cipher={cipher} />
|
<VaultListIcon cipher={cipher} />
|
||||||
</div>
|
</div>
|
||||||
<div className="list-text">
|
<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>
|
<span className="list-sub" title={listSubtitle(cipher)}>{listSubtitle(cipher)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -1551,6 +1678,25 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{!!selectedCipher.login.decTotp && (
|
||||||
<div className="kv-row">
|
<div className="kv-row">
|
||||||
<span className="kv-label">{t('txt_totp')}</span>
|
<span className="kv-label">{t('txt_totp')}</span>
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
|
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
|
||||||
txt_expiration_date: "Expiration Date",
|
txt_expiration_date: "Expiration Date",
|
||||||
txt_expiration_days_0_never: "Expiration Days (0 = never)",
|
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: "Expires At",
|
||||||
txt_expires_at_value: "Expires at: {value}",
|
txt_expires_at_value: "Expires at: {value}",
|
||||||
txt_expiry: "Expiry",
|
txt_expiry: "Expiry",
|
||||||
@@ -249,6 +254,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_no: "No",
|
txt_no: "No",
|
||||||
txt_no_devices_found: "No devices found.",
|
txt_no_devices_found: "No devices found.",
|
||||||
txt_no_folder: "No Folder",
|
txt_no_folder: "No Folder",
|
||||||
|
txt_no_exposed_passwords_found: "No exposed passwords found",
|
||||||
txt_no_items: "No items",
|
txt_no_items: "No items",
|
||||||
txt_no_username: "(No username)",
|
txt_no_username: "(No username)",
|
||||||
txt_no_verification_codes: "No verification codes",
|
txt_no_verification_codes: "No verification codes",
|
||||||
@@ -292,6 +298,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_regenerate: "Regenerate",
|
txt_regenerate: "Regenerate",
|
||||||
txt_registration_succeeded_please_sign_in: "Registration succeeded. Please sign in.",
|
txt_registration_succeeded_please_sign_in: "Registration succeeded. Please sign in.",
|
||||||
txt_remove: "Remove",
|
txt_remove: "Remove",
|
||||||
|
txt_ignore: "Ignore",
|
||||||
txt_remove_device: "Remove device",
|
txt_remove_device: "Remove device",
|
||||||
txt_remove_device_2: "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?",
|
txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?",
|
||||||
@@ -375,6 +382,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_unlock_item: "Unlock Item",
|
txt_unlock_item: "Unlock Item",
|
||||||
txt_unlock_send: "Unlock Send",
|
txt_unlock_send: "Unlock Send",
|
||||||
txt_unlock_vault: "Unlock Vault",
|
txt_unlock_vault: "Unlock Vault",
|
||||||
|
txt_unignore: "Unignore",
|
||||||
txt_unlocked: "Unlocked",
|
txt_unlocked: "Unlocked",
|
||||||
txt_update_item_failed: "Update item failed",
|
txt_update_item_failed: "Update item failed",
|
||||||
txt_update_send_failed: "Update send failed",
|
txt_update_send_failed: "Update send failed",
|
||||||
@@ -433,6 +441,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_back_to_login: '返回登录',
|
txt_back_to_login: '返回登录',
|
||||||
txt_unlock: '解锁',
|
txt_unlock: '解锁',
|
||||||
txt_unlock_vault: '解锁密码库',
|
txt_unlock_vault: '解锁密码库',
|
||||||
|
txt_unignore: '取消忽略',
|
||||||
txt_master_password: '主密码',
|
txt_master_password: '主密码',
|
||||||
txt_email: '邮箱',
|
txt_email: '邮箱',
|
||||||
txt_name: '名称',
|
txt_name: '名称',
|
||||||
@@ -459,6 +468,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_copy: '复制',
|
txt_copy: '复制',
|
||||||
txt_code_copied: '验证码已复制',
|
txt_code_copied: '验证码已复制',
|
||||||
txt_copy_link: '复制链接',
|
txt_copy_link: '复制链接',
|
||||||
|
txt_ignore: '忽略',
|
||||||
txt_select_all: '全选',
|
txt_select_all: '全选',
|
||||||
txt_delete_selected: '删除所选',
|
txt_delete_selected: '删除所选',
|
||||||
txt_all_items: '所有项目',
|
txt_all_items: '所有项目',
|
||||||
@@ -467,6 +477,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_folder: '文件夹',
|
txt_folder: '文件夹',
|
||||||
txt_folders: '文件夹',
|
txt_folders: '文件夹',
|
||||||
txt_no_folder: '无文件夹',
|
txt_no_folder: '无文件夹',
|
||||||
|
txt_no_exposed_passwords_found: '未发现已泄露密码',
|
||||||
txt_no_items: '没有项目',
|
txt_no_items: '没有项目',
|
||||||
txt_no_username: '无用户名',
|
txt_no_username: '无用户名',
|
||||||
txt_no_verification_codes: '没有验证码',
|
txt_no_verification_codes: '没有验证码',
|
||||||
@@ -474,6 +485,11 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_select_an_item: '请选择一个项目',
|
txt_select_an_item: '请选择一个项目',
|
||||||
txt_login: '登录',
|
txt_login: '登录',
|
||||||
txt_card: '银行卡',
|
txt_card: '银行卡',
|
||||||
|
txt_exposed: '已泄露',
|
||||||
|
txt_exposed_password_check_complete_count: '发现 {count} 个已泄露密码',
|
||||||
|
txt_exposed_ignored: '已泄露(已忽略)',
|
||||||
|
txt_exposed_passwords: '是否泄露',
|
||||||
|
txt_exposed_short: '泄露',
|
||||||
txt_identity: '身份',
|
txt_identity: '身份',
|
||||||
txt_note: '笔记',
|
txt_note: '笔记',
|
||||||
txt_secure_note: '安全笔记',
|
txt_secure_note: '安全笔记',
|
||||||
|
|||||||
@@ -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<string, { expiresAt: number; suffixes: Set<string> }>();
|
||||||
|
const inflightRangeRequests = new Map<string, Promise<Set<string>>>();
|
||||||
|
|
||||||
|
function normalizeHashHex(value: string): string {
|
||||||
|
return String(value || '').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha1Hex(input: string): Promise<string> {
|
||||||
|
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<string> | 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<string>): 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<Set<string>> {
|
||||||
|
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<string>();
|
||||||
|
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<Record<string, boolean>> {
|
||||||
|
const loginCiphers = ciphers.filter((cipher) => {
|
||||||
|
const password = String(cipher.login?.decPassword || '').trim();
|
||||||
|
return cipher.type === 1 && !!cipher.id && !!password;
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniquePasswords = new Map<string, string>();
|
||||||
|
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<string, Set<string>>();
|
||||||
|
await Promise.all(
|
||||||
|
prefixes.map(async (prefix) => {
|
||||||
|
rangeMap.set(prefix, await getRangeSuffixes(prefix));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: Record<string, boolean> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -278,7 +278,7 @@ export interface TokenError {
|
|||||||
|
|
||||||
export interface ToastMessage {
|
export interface ToastMessage {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'success' | 'error';
|
type: 'success' | 'error' | 'warning';
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-1
@@ -875,15 +875,41 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-title {
|
.list-title {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
color: #175ddc;
|
color: #175ddc;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title-text {
|
||||||
|
min-width: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.list-sub {
|
||||||
display: block;
|
display: block;
|
||||||
color: #5f6f85;
|
color: #5f6f85;
|
||||||
@@ -1123,6 +1149,14 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exposed-status {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exposed-status.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.attachment-list {
|
.attachment-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -1755,6 +1789,12 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
color: #9f1239;
|
color: #9f1239;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-item.warning {
|
||||||
|
border-color: #f2b8c1;
|
||||||
|
background: #fde7eb;
|
||||||
|
color: #9f1239;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-text {
|
.toast-text {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user