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:
@@ -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_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<Locale, Record<string, string>> = {
|
||||
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<Locale, Record<string, string>> = {
|
||||
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<Locale, Record<string, string>> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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: '安全笔记',
|
||||
|
||||
@@ -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 {
|
||||
id: string;
|
||||
type: 'success' | 'error';
|
||||
type: 'success' | 'error' | 'warning';
|
||||
text: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user