feat: add Password Security feature with scanning and reporting capabilities

This commit is contained in:
shuaiplus
2026-07-12 01:50:21 +08:00
parent dfc98008cb
commit 99b50275a6
22 changed files with 847 additions and 10 deletions
+8
View File
@@ -1448,4 +1448,12 @@ const de: Record<string, string> = {
"txt_ip_address": "IP-Adresse"
};
Object.assign(de, {
"nav_password_security": "Passwortsicherheit", "txt_password_security": "Passwort-Sicherheitsprüfung", "txt_password_security_privacy": "Passwörter werden lokal geprüft. Erst nach dem Start wird nur ein anonymer Hash-Präfix an die Leckdatenbank gesendet.", "txt_check_password_security": "Prüfung starten", "txt_checking_password_security": "Prüfung läuft", "txt_recheck_password_security": "Erneut prüfen", "txt_password_security_ready": "Ihr Tresor ist für eine Sicherheitsprüfung bereit.", "txt_password_security_no_login": "Es gibt keine Login-Passwörter zu prüfen.", "txt_password_security_manual": "Die Prüfung startet nur auf Ihre Anfrage. Ergebnisse bleiben nur auf dieser Seite.", "txt_password_security_no_login_help": "Fügen Sie einen Login-Eintrag mit Passwort hinzu und prüfen Sie ihn anschließend hier.", "txt_exposed_passwords": "Geleakt", "txt_reused_passwords": "Wiederverwendet", "txt_weak_passwords": "Schwach", "txt_passwords_checked": "Geprüft", "txt_password_security_unavailable": "{count} Passwortprüfungen konnten die Leckdatenbank nicht erreichen. Sie werden nicht als sicher markiert.", "txt_password_security_not_checked": "Nicht geprüft", "txt_password_exposed_count": "In {count} Lecks gefunden", "txt_password_reused_count": "{count}-mal verwendet", "txt_weak_password": "Schwaches Passwort", "txt_no_password_risks": "Keine Passwortrisiken gefunden", "txt_open_vault": "Tresor öffnen", "txt_check_password_breach": "Leck prüfen", "txt_password_not_found_in_breaches": "Nicht in der Leckdatenbank gefunden", "txt_password_security_check_failed": "Die Leckprüfung konnte nicht abgeschlossen werden."
});
Object.assign(de, { "txt_password_security_last_checked": "Zuletzt überprüft: {value}" });
Object.assign(de, { "txt_no_password_risks_in_filter": "Keine Passwortrisiken in dieser Kategorie" });
Object.assign(de, { "txt_password_security_show_all": "Alle anzeigen", "txt_password_security_hide_all": "Alle ausblenden", "txt_password_security_jump": "Öffnen", "txt_password_security_exposed_short": "{count}-mal geleakt", "txt_password_security_weak_short": "Schwaches Passwort", "txt_password_security_reused_short": "Wiederverwendet" });
export default de;
+35
View File
@@ -1471,4 +1471,39 @@ const en: Record<string, string> = {
"txt_ip_address": "IP address"
};
Object.assign(en, {
"nav_password_security": "Password Security",
"txt_password_security": "Password Security Check",
"txt_password_security_privacy": "Passwords are checked locally in your browser. Only an anonymous hash prefix is sent to the breach database.",
"txt_check_password_security": "Start check",
"txt_checking_password_security": "Checking",
"txt_recheck_password_security": "Check again",
"txt_password_security_ready": "Your vault is ready for a security check.",
"txt_password_security_no_login": "There are no login passwords to check.",
"txt_password_security_manual": "The check only starts when you choose it. Results are kept until you refresh or your vault changes.",
"txt_password_security_no_login_help": "Add a login item with a password, then return here to check it.",
"txt_exposed_passwords": "Exposed",
"txt_reused_passwords": "Reused",
"txt_weak_passwords": "Weak",
"txt_passwords_checked": "Checked",
"txt_password_security_last_checked": "Last checked: {value}",
"txt_password_security_show_all": "Show all",
"txt_password_security_hide_all": "Hide all",
"txt_password_security_jump": "Go to item",
"txt_password_security_exposed_short": "Exposed {count} times",
"txt_password_security_weak_short": "Weak password",
"txt_password_security_reused_short": "Reused",
"txt_password_security_unavailable": "{count} password checks could not reach the breach database. They are not marked safe.",
"txt_password_security_not_checked": "Not checked",
"txt_password_exposed_count": "Found in {count} breaches",
"txt_password_reused_count": "Used {count} times",
"txt_weak_password": "Weak password",
"txt_no_password_risks": "No password risks found",
"txt_no_password_risks_in_filter": "No password risks in this category",
"txt_open_vault": "Open vault",
"txt_check_password_breach": "Check breach",
"txt_password_not_found_in_breaches": "Not found in the breach database",
"txt_password_security_check_failed": "The breach check could not be completed."
});
export default en;
+8
View File
@@ -1448,4 +1448,12 @@ const es: Record<string, string> = {
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
};
Object.assign(es, {
"nav_password_security": "Seguridad de contraseñas", "txt_password_security": "Comprobación de seguridad", "txt_password_security_privacy": "Las contraseñas se comprueban localmente. Solo se envía un prefijo de hash anónimo a la base de filtraciones al iniciar la comprobación.", "txt_check_password_security": "Iniciar comprobación", "txt_checking_password_security": "Comprobando", "txt_recheck_password_security": "Comprobar de nuevo", "txt_password_security_ready": "Tu bóveda está lista para una comprobación de seguridad.", "txt_password_security_no_login": "No hay contraseñas de inicio de sesión para comprobar.", "txt_password_security_manual": "La comprobación solo empieza cuando la eliges. Los resultados se conservan solo en esta página.", "txt_password_security_no_login_help": "Añade un inicio de sesión con contraseña y vuelve aquí para comprobarlo.", "txt_exposed_passwords": "Filtradas", "txt_reused_passwords": "Reutilizadas", "txt_weak_passwords": "Débiles", "txt_passwords_checked": "Comprobadas", "txt_password_security_unavailable": "{count} comprobaciones no pudieron acceder a la base de filtraciones. No se marcan como seguras.", "txt_password_security_not_checked": "Sin comprobar", "txt_password_exposed_count": "Encontrada en {count} filtraciones", "txt_password_reused_count": "Usada {count} veces", "txt_weak_password": "Contraseña débil", "txt_no_password_risks": "No se encontraron riesgos de contraseña", "txt_open_vault": "Abrir bóveda", "txt_check_password_breach": "Comprobar filtración", "txt_password_not_found_in_breaches": "No encontrada en la base de filtraciones", "txt_password_security_check_failed": "No se pudo completar la comprobación de filtraciones."
});
Object.assign(es, { "txt_password_security_last_checked": "Última comprobación: {value}" });
Object.assign(es, { "txt_no_password_risks_in_filter": "No hay riesgos de contraseña en esta categoría" });
Object.assign(es, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default es;
+8
View File
@@ -1448,4 +1448,12 @@ const fi: Record<string, string> = {
"txt_ip_address": "IP-osoite"
};
Object.assign(fi, {
"nav_password_security": "Salasanasuojaus", "txt_password_security": "Salasanojen turvatarkistus", "txt_password_security_privacy": "Salasanat tarkistetaan paikallisesti. Vain anonyymi hajautteen alku lähetetään vuototietokantaan tarkistuksen alkaessa.", "txt_check_password_security": "Aloita tarkistus", "txt_checking_password_security": "Tarkistetaan", "txt_recheck_password_security": "Tarkista uudelleen", "txt_password_security_ready": "Holvisi on valmis turvatarkistukseen.", "txt_password_security_no_login": "Tarkistettavia kirjautumissalasanoja ei ole.", "txt_password_security_manual": "Tarkistus käynnistyy vain valinnastasi. Tulokset säilyvät vain tällä sivulla.", "txt_password_security_no_login_help": "Lisää kirjautuminen salasanalla ja palaa sitten tarkistamaan se.", "txt_exposed_passwords": "Vuotaneet", "txt_reused_passwords": "Uudelleenkäytetyt", "txt_weak_passwords": "Heikot", "txt_passwords_checked": "Tarkistettu", "txt_password_security_unavailable": "{count} salasanatarkistusta ei tavoittanut vuototietokantaa. Niitä ei merkitä turvallisiksi.", "txt_password_security_not_checked": "Ei tarkistettu", "txt_password_exposed_count": "Löytyi {count} vuodosta", "txt_password_reused_count": "Käytetty {count} kertaa", "txt_weak_password": "Heikko salasana", "txt_no_password_risks": "Salasanariskejä ei löytynyt", "txt_open_vault": "Avaa holvi", "txt_check_password_breach": "Tarkista vuoto", "txt_password_not_found_in_breaches": "Ei löytynyt vuototietokannasta", "txt_password_security_check_failed": "Vuototarkistusta ei voitu suorittaa."
});
Object.assign(fi, { "txt_password_security_last_checked": "Tarkistettu viimeksi: {value}" });
Object.assign(fi, { "txt_no_password_risks_in_filter": "Tässä luokassa ei ole salasanojen riskejä" });
Object.assign(fi, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default fi;
+8
View File
@@ -1448,4 +1448,12 @@ const fr: Record<string, string> = {
"txt_ip_address": "Adresse IP"
};
Object.assign(fr, {
"nav_password_security": "Sécurité des mots de passe", "txt_password_security": "Vérification de sécurité", "txt_password_security_privacy": "Les mots de passe sont vérifiés localement. Seul un préfixe de hachage anonyme est envoyé à la base de fuites après le démarrage.", "txt_check_password_security": "Lancer la vérification", "txt_checking_password_security": "Vérification", "txt_recheck_password_security": "Vérifier à nouveau", "txt_password_security_ready": "Votre coffre est prêt pour une vérification de sécurité.", "txt_password_security_no_login": "Aucun mot de passe de connexion à vérifier.", "txt_password_security_manual": "La vérification ne démarre que sur votre demande. Les résultats restent sur cette page.", "txt_password_security_no_login_help": "Ajoutez une connexion avec mot de passe, puis revenez ici pour la vérifier.", "txt_exposed_passwords": "Exposés", "txt_reused_passwords": "Réutilisés", "txt_weak_passwords": "Faibles", "txt_passwords_checked": "Vérifiés", "txt_password_security_unavailable": "{count} vérifications n'ont pas pu joindre la base de fuites. Elles ne sont pas marquées comme sûres.", "txt_password_security_not_checked": "Non vérifié", "txt_password_exposed_count": "Trouvé dans {count} fuites", "txt_password_reused_count": "Utilisé {count} fois", "txt_weak_password": "Mot de passe faible", "txt_no_password_risks": "Aucun risque de mot de passe détecté", "txt_open_vault": "Ouvrir le coffre", "txt_check_password_breach": "Vérifier la fuite", "txt_password_not_found_in_breaches": "Introuvable dans la base de fuites", "txt_password_security_check_failed": "La vérification de fuite n'a pas pu être terminée."
});
Object.assign(fr, { "txt_password_security_last_checked": "Dernière vérification : {value}" });
Object.assign(fr, { "txt_no_password_risks_in_filter": "Aucun risque de mot de passe dans cette catégorie" });
Object.assign(fr, { "txt_password_security_show_all": "Tout afficher", "txt_password_security_hide_all": "Tout masquer", "txt_password_security_jump": "Ouvrir", "txt_password_security_exposed_short": "Exposé {count} fois", "txt_password_security_weak_short": "Mot de passe faible", "txt_password_security_reused_short": "Réutilisé" });
export default fr;
+8
View File
@@ -1448,4 +1448,12 @@ const it: Record<string, string> = {
"txt_ip_address": "Indirizzo IP"
};
Object.assign(it, {
"nav_password_security": "Sicurezza password", "txt_password_security": "Controllo sicurezza password", "txt_password_security_privacy": "Le password vengono controllate localmente. Solo un prefisso hash anonimo viene inviato al database delle violazioni dopo l'avvio.", "txt_check_password_security": "Avvia controllo", "txt_checking_password_security": "Controllo in corso", "txt_recheck_password_security": "Controlla di nuovo", "txt_password_security_ready": "Il tuo archivio è pronto per un controllo di sicurezza.", "txt_password_security_no_login": "Non ci sono password di accesso da controllare.", "txt_password_security_manual": "Il controllo parte solo quando lo scegli. I risultati restano solo in questa pagina.", "txt_password_security_no_login_help": "Aggiungi un accesso con password, quindi torna qui per controllarlo.", "txt_exposed_passwords": "Esposte", "txt_reused_passwords": "Riutilizzate", "txt_weak_passwords": "Deboli", "txt_passwords_checked": "Controllate", "txt_password_security_unavailable": "{count} controlli non hanno raggiunto il database delle violazioni. Non sono contrassegnati come sicuri.", "txt_password_security_not_checked": "Non controllata", "txt_password_exposed_count": "Trovata in {count} violazioni", "txt_password_reused_count": "Usata {count} volte", "txt_weak_password": "Password debole", "txt_no_password_risks": "Nessun rischio password trovato", "txt_open_vault": "Apri archivio", "txt_check_password_breach": "Controlla violazione", "txt_password_not_found_in_breaches": "Non trovata nel database delle violazioni", "txt_password_security_check_failed": "Impossibile completare il controllo delle violazioni."
});
Object.assign(it, { "txt_password_security_last_checked": "Ultimo controllo: {value}" });
Object.assign(it, { "txt_no_password_risks_in_filter": "Nessun rischio password in questa categoria" });
Object.assign(it, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default it;
+8
View File
@@ -1448,4 +1448,12 @@ const ru: Record<string, string> = {
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
};
Object.assign(ru, {
"nav_password_security": "Безопасность паролей", "txt_password_security": "Проверка безопасности паролей", "txt_password_security_privacy": "Пароли проверяются локально. После запуска в базу утечек передаётся только анонимный префикс хеша.", "txt_check_password_security": "Начать проверку", "txt_checking_password_security": "Проверка", "txt_recheck_password_security": "Проверить снова", "txt_password_security_ready": "Ваше хранилище готово к проверке безопасности.", "txt_password_security_no_login": "Нет паролей для входа, доступных для проверки.", "txt_password_security_manual": "Проверка запускается только по вашему выбору. Результаты остаются только на этой странице.", "txt_password_security_no_login_help": "Добавьте запись входа с паролем и вернитесь сюда для проверки.", "txt_exposed_passwords": "Скомпрометированы", "txt_reused_passwords": "Повторно используются", "txt_weak_passwords": "Слабые", "txt_passwords_checked": "Проверено", "txt_password_security_unavailable": "{count} проверок не смогли обратиться к базе утечек. Они не помечены безопасными.", "txt_password_security_not_checked": "Не проверено", "txt_password_exposed_count": "Найдено в {count} утечках", "txt_password_reused_count": "Используется {count} раз", "txt_weak_password": "Слабый пароль", "txt_no_password_risks": "Рисков паролей не найдено", "txt_open_vault": "Открыть хранилище", "txt_check_password_breach": "Проверить утечку", "txt_password_not_found_in_breaches": "Не найден в базе утечек", "txt_password_security_check_failed": "Не удалось завершить проверку утечки."
});
Object.assign(ru, { "txt_password_security_last_checked": "Последняя проверка: {value}" });
Object.assign(ru, { "txt_no_password_risks_in_filter": "В этой категории нет рисков для паролей" });
Object.assign(ru, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default ru;
+8
View File
@@ -1448,4 +1448,12 @@ const sv: Record<string, string> = {
"txt_ip_address": "IP-adress"
};
Object.assign(sv, {
"nav_password_security": "Lösenordssäkerhet", "txt_password_security": "Säkerhetskontroll för lösenord", "txt_password_security_privacy": "Lösenord kontrolleras lokalt. Endast ett anonymt hashprefix skickas till läckdatabasen när du startar kontrollen.", "txt_check_password_security": "Starta kontroll", "txt_checking_password_security": "Kontrollerar", "txt_recheck_password_security": "Kontrollera igen", "txt_password_security_ready": "Ditt valv är redo för en säkerhetskontroll.", "txt_password_security_no_login": "Det finns inga inloggningslösenord att kontrollera.", "txt_password_security_manual": "Kontrollen startar bara när du väljer den. Resultaten stannar på denna sida.", "txt_password_security_no_login_help": "Lägg till en inloggning med lösenord och återvänd sedan hit för att kontrollera den.", "txt_exposed_passwords": "Läckta", "txt_reused_passwords": "Återanvända", "txt_weak_passwords": "Svaga", "txt_passwords_checked": "Kontrollerade", "txt_password_security_unavailable": "{count} lösenordskontroller kunde inte nå läckdatabasen. De markeras inte som säkra.", "txt_password_security_not_checked": "Inte kontrollerad", "txt_password_exposed_count": "Hittades i {count} läckor", "txt_password_reused_count": "Användes {count} gånger", "txt_weak_password": "Svagt lösenord", "txt_no_password_risks": "Inga lösenordsrisker hittades", "txt_open_vault": "Öppna valv", "txt_check_password_breach": "Kontrollera läcka", "txt_password_not_found_in_breaches": "Hittades inte i läckdatabasen", "txt_password_security_check_failed": "Läckkontrollen kunde inte slutföras."
});
Object.assign(sv, { "txt_password_security_last_checked": "Senast kontrollerad: {value}" });
Object.assign(sv, { "txt_no_password_risks_in_filter": "Inga lösenordsrisker i denna kategori" });
Object.assign(sv, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default sv;
+35
View File
@@ -1451,4 +1451,39 @@ const zhCN: Record<string, string> = {
"txt_ip_address": "IP 地址"
};
Object.assign(zhCN, {
"nav_password_security": "安全检测",
"txt_password_security": "安全检测",
"txt_password_security_privacy": "密码仅在本地前端检查;只有匿名哈希前缀会发送到泄露密码库。",
"txt_check_password_security": "开始检查",
"txt_checking_password_security": "检查中",
"txt_recheck_password_security": "重新检查",
"txt_password_security_ready": "密码库已准备好进行安全检查。",
"txt_password_security_no_login": "没有可检查的登录密码。",
"txt_password_security_manual": "仅在您主动开始后才会联网检查;结果会保留到刷新页面或密码库内容变更前。",
"txt_password_security_no_login_help": "添加一个包含密码的登录项目后,再回到此处检查。",
"txt_exposed_passwords": "已泄露",
"txt_reused_passwords": "重复使用",
"txt_weak_passwords": "较弱",
"txt_passwords_checked": "已检查",
"txt_password_security_last_checked": "上次检测:{value}",
"txt_password_security_show_all": "显示全部",
"txt_password_security_hide_all": "隐藏全部",
"txt_password_security_jump": "跳转",
"txt_password_security_exposed_short": "泄露 {count} 次",
"txt_password_security_weak_short": "弱密码",
"txt_password_security_reused_short": "重复",
"txt_password_security_unavailable": "有 {count} 个密码无法连接泄露库,未被标记为安全。",
"txt_password_security_not_checked": "未检查",
"txt_password_exposed_count": "已在 {count} 次泄露中出现",
"txt_password_reused_count": "使用了 {count} 次",
"txt_weak_password": "较弱密码",
"txt_no_password_risks": "未发现密码风险",
"txt_no_password_risks_in_filter": "此类别中没有密码风险",
"txt_open_vault": "打开密码库",
"txt_check_password_breach": "检查泄露",
"txt_password_not_found_in_breaches": "未在泄露密码库中发现",
"txt_password_security_check_failed": "无法完成泄露检查。"
});
export default zhCN;
+35
View File
@@ -1451,4 +1451,39 @@ const zhTW: Record<string, string> = {
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
};
Object.assign(zhTW, {
"nav_password_security": "密碼安全",
"txt_password_security": "密碼安全檢查",
"txt_password_security_privacy": "密碼僅在本機前端檢查;只有匿名雜湊前綴會傳送到外洩密碼庫。",
"txt_check_password_security": "開始檢查",
"txt_checking_password_security": "檢查中",
"txt_recheck_password_security": "重新檢查",
"txt_password_security_ready": "密碼庫已準備好進行安全檢查。",
"txt_password_security_no_login": "沒有可檢查的登入密碼。",
"txt_password_security_manual": "僅在您主動開始後才會連線檢查;結果會保留到重新整理頁面或密碼庫內容變更前。",
"txt_password_security_no_login_help": "新增一個含有密碼的登入項目後,再回到此處檢查。",
"txt_exposed_passwords": "已外洩",
"txt_reused_passwords": "重複使用",
"txt_weak_passwords": "較弱",
"txt_passwords_checked": "已檢查",
"txt_password_security_last_checked": "上次檢查:{value}",
"txt_password_security_show_all": "顯示全部",
"txt_password_security_hide_all": "隱藏全部",
"txt_password_security_jump": "跳轉",
"txt_password_security_exposed_short": "外洩 {count} 次",
"txt_password_security_weak_short": "弱密碼",
"txt_password_security_reused_short": "重複",
"txt_password_security_unavailable": "有 {count} 個密碼無法連線至外洩資料庫,未被標記為安全。",
"txt_password_security_not_checked": "未檢查",
"txt_password_exposed_count": "已在 {count} 次外洩中出現",
"txt_password_reused_count": "使用了 {count} 次",
"txt_weak_password": "較弱密碼",
"txt_no_password_risks": "未發現密碼風險",
"txt_no_password_risks_in_filter": "此類別中沒有密碼風險",
"txt_open_vault": "開啟密碼庫",
"txt_check_password_breach": "檢查外洩",
"txt_password_not_found_in_breaches": "未在外洩密碼庫中發現",
"txt_password_security_check_failed": "無法完成外洩檢查。"
});
export default zhTW;
+74
View File
@@ -0,0 +1,74 @@
import { inspectVaultPasswordSecurity, type PasswordSecurityReport } from '@/lib/password-security';
import type { Cipher } from '@/lib/types';
export interface PasswordSecurityState {
fingerprint: string;
report: PasswordSecurityReport | null;
scannedAt: number | null;
scanning: boolean;
progress: { checked: number; total: number };
scanError: boolean;
}
type InternalPasswordSecurityState = PasswordSecurityState & { controller: AbortController | null };
let state: InternalPasswordSecurityState | null = null;
const listeners = new Set<() => void>();
function notify(): void {
listeners.forEach((listener) => listener());
}
function createState(fingerprint: string): InternalPasswordSecurityState {
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
}
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
if (state?.fingerprint !== fingerprint) {
state?.controller?.abort();
state = createState(fingerprint);
}
return state;
}
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
return state?.fingerprint === fingerprint ? state : null;
}
export function subscribePasswordSecurityState(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
const current = getPasswordSecurityState(fingerprint);
current.controller?.abort();
const controller = new AbortController();
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
state = { ...current, report: null, scannedAt: null, scanning: true, progress: { checked: 0, total }, scanError: false, controller };
notify();
void (async () => {
try {
const report = await inspectVaultPasswordSecurity(ciphers, (checked, total) => {
if (controller.signal.aborted || state?.controller !== controller) return;
state = { ...state, progress: { checked, total } };
notify();
}, fetch, controller.signal);
if (controller.signal.aborted || state?.controller !== controller) return;
state = { ...state, report, scannedAt: Date.now() };
} catch (error) {
if (controller.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return;
if (state?.controller === controller) state = { ...state, scanError: true };
} finally {
if (state?.controller === controller) state = { ...state, controller: null, scanning: false };
notify();
}
})();
}
export function clearPasswordSecurityCache(): void {
state?.controller?.abort();
state = null;
notify();
}
+229
View File
@@ -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,
};
}