mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-10 17:00:12 +00:00
feat: add Password Security feature with scanning and reporting capabilities
This commit is contained in:
@@ -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<string> {
|
||||
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<number> {
|
||||
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<PasswordBreachResult> {
|
||||
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<T, R>(
|
||||
values: T[],
|
||||
limit: number,
|
||||
worker: (value: T) => Promise<R>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(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<PasswordSecurityReport> {
|
||||
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<string, Candidate[]>();
|
||||
for (const candidate of candidates) {
|
||||
const group = candidatesByHash.get(candidate.hash) || [];
|
||||
group.push(candidate);
|
||||
candidatesByHash.set(candidate.hash, group);
|
||||
}
|
||||
|
||||
const exposureByHash = new Map<string, PasswordBreachResult>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user