feat(security): HIBP password breach check + CSP tightening
HIBP integration ================ Opt-in (default OFF) password breach check via the Have I Been Pwned range API. The full master / entry password never leaves the machine — only the first 5 characters of its SHA-1 hash. HIBP returns ~500 candidate suffixes; the client matches its own suffix locally. UI: - New "Check passwords against breach database (HIBP)" toggle in Settings → Security with an explainer hint about k-anonymity. - On enable: background batch scan of all entries, results cached in state.hibpResults keyed by entry id. Concurrency capped at 6 to avoid hammering HIBP / hitting browser connection limits. - Entry cards show a red "Pwned" chip + breach count in the tooltip when count > 0. New i-alert icon added to the SVG sprite. - Auto-scan triggered after every enterApp() when the toggle is on. Functions added to app.js: - sha1Hex(text) — crypto.subtle wrapper - hibpCheckPassword(plaintext) — single-password check, returns count - hibpCheckAllEntries() — batched scan over state.entries The "Add-Padding: true" header is sent on every range request to defeat the response-size side-channel (HIBP adds 800-1000 random extra entries so an observer counting bytes can't narrow the prefix queried). CSP tightening ============== Audited the served HTML: zero <script> tags inline, only the external js/app.js. Removed 'unsafe-inline' from script-src — real XSS defense. Kept 'unsafe-inline' on style-src for now because index.html contains inline style="" attributes and app.js calls element.style.cssText extensively. Refactoring to CSS classes is a separate cleanup. Style injection alone cannot execute code, so the residual risk is bounded to visual manipulation in a single-user loopback app. Added api.pwnedpasswords.com to connect-src as the only allowed external origin (required by the HIBP feature above). Default still 'self' — everything else stays loopback. Before: script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; After: script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.pwnedpasswords.com;
This commit is contained in:
+1206
-324
File diff suppressed because it is too large
Load Diff
@@ -138,9 +138,22 @@ var
|
||||
begin
|
||||
AResponse.CustomHeaders.Values['Strict-Transport-Security'] :=
|
||||
'max-age=31536000; includeSubDomains';
|
||||
// Content-Security-Policy — tightened May 2026:
|
||||
// - script-src: removed 'unsafe-inline'. No <script> tags inline in the
|
||||
// served HTML — only external js/app.js. Real XSS defense.
|
||||
// - style-src: KEPT 'unsafe-inline' because index.html has inline
|
||||
// style="" attributes and app.js sets element.style.cssText
|
||||
// extensively. Refactoring that to use CSS classes would be a
|
||||
// separate cleanup pass. Style injection alone cannot execute code,
|
||||
// so the risk is bounded to visual manipulation / data exfil via
|
||||
// CSS selectors (low impact in a single-user loopback app).
|
||||
// - connect-src: 'self' + api.pwnedpasswords.com to allow the HIBP
|
||||
// range API. Only the SHA-1[0..5] prefix ever leaves the machine.
|
||||
AResponse.CustomHeaders.Values['Content-Security-Policy'] :=
|
||||
'default-src ''self''; script-src ''self'' ''unsafe-inline''; ' +
|
||||
'style-src ''self'' ''unsafe-inline''; connect-src ''self''; ' +
|
||||
'default-src ''self''; ' +
|
||||
'script-src ''self''; ' +
|
||||
'style-src ''self'' ''unsafe-inline''; ' +
|
||||
'connect-src ''self'' https://api.pwnedpasswords.com; ' +
|
||||
'img-src ''self'' data:; font-src ''self''; form-action ''self''; ' +
|
||||
'frame-ancestors ''none''; base-uri ''self''; object-src ''none''';
|
||||
AResponse.CustomHeaders.Values['X-Content-Type-Options'] := 'nosniff';
|
||||
|
||||
+483
-189
@@ -1,218 +1,512 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🔐 Vault</title>
|
||||
<title>Vault</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
<div class="idle-warning" id="idleWarning">
|
||||
<h3>⏰ Auto-lock</h3>
|
||||
<p>Vault locks in <span id="idleCountdown">30</span>s</p>
|
||||
<button class="btn btn-sm" onclick="resetIdle()">Stay Unlocked</button>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ICON SPRITE (Lucide subset) -->
|
||||
<!-- ============================================================ -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
|
||||
<symbol id="i-unlock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
|
||||
<symbol id="i-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="i-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61M2 2l20 20"/></symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></symbol>
|
||||
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/></symbol>
|
||||
<symbol id="i-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z"/></symbol>
|
||||
<symbol id="i-dice" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="16" cy="8" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="8" cy="16" r="1.2" fill="currentColor"/><circle cx="16" cy="16" r="1.2" fill="currentColor"/></symbol>
|
||||
<symbol id="i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82Z"/><line x1="7" y1="7" x2="7.01" y2="7"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></symbol>
|
||||
<symbol id="i-rotate-ccw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="i-command" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3Z"/></symbol>
|
||||
<symbol id="i-log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.4" fill="currentColor"/><circle cx="12" cy="12" r="1.4" fill="currentColor"/><circle cx="12" cy="19" r="1.4" fill="currentColor"/></symbol>
|
||||
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
|
||||
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
|
||||
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
|
||||
<rect x="40" y="50" width="40" height="35" rx="4" fill="var(--bg-elev)" stroke="var(--accent)" stroke-width="2"/>
|
||||
<path d="M48 50V40a12 12 0 0 1 24 0v10" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="60" cy="65" r="3" fill="var(--accent)"/>
|
||||
<line x1="60" y1="68" x2="60" y2="74" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-empty-trash" viewBox="0 0 120 120" fill="none">
|
||||
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
|
||||
<path d="M40 45h40M48 45v-5a3 3 0 0 1 3-3h18a3 3 0 0 1 3 3v5M44 45v35a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4V45" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M54 55v22M66 55v22" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="i-empty-search" viewBox="0 0 120 120" fill="none">
|
||||
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
|
||||
<circle cx="55" cy="55" r="14" stroke="var(--accent)" stroke-width="2.5" fill="none"/>
|
||||
<path d="m66 66 11 11" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TOAST CONTAINER -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- IDLE WARNING (auto-lock countdown) -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="idleWarning" class="idle-warning is-hidden">
|
||||
<svg class="idle-icon"><use href="#i-lock"/></svg>
|
||||
<h3>Auto-lock</h3>
|
||||
<p>Vault locks in <b id="idleCountdown">30</b>s due to inactivity</p>
|
||||
<button class="btn btn-primary btn-block" id="idleStayBtn">Stay unlocked</button>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- AUTH SCREEN (login + register) -->
|
||||
<!-- ============================================================ -->
|
||||
<section id="authScreen" class="auth-screen">
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand">
|
||||
<svg class="auth-brand-icon"><use href="#i-lock"/></svg>
|
||||
<h1>Vault</h1>
|
||||
<p>Your passwords, encrypted locally.</p>
|
||||
</div>
|
||||
|
||||
<div class="edit-modal" id="editModal">
|
||||
<div class="edit-box">
|
||||
<h3>✏️ Edit Entry</h3>
|
||||
<label>Website / App</label><input type="text" id="editSite" placeholder="example.com">
|
||||
<label style="margin-top:.6rem">Username / Email</label><input type="text" id="editUsername" placeholder="user@example.com">
|
||||
<label style="margin-top:.6rem">Password</label>
|
||||
<div style="position:relative">
|
||||
<input type="password" id="editPassword" placeholder="Password" style="width:100%;padding-right:40px">
|
||||
<button type="button" onclick="toggleEditPassword()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:.9rem">👁️</button>
|
||||
</div>
|
||||
<label style="margin-top:.6rem">Folder</label>
|
||||
<select id="editFolder" style="width:100%;margin-bottom:.4rem"></select>
|
||||
<input type="hidden" id="editId">
|
||||
<div style="display:flex;gap:.5rem;margin-top:1rem">
|
||||
<button class="btn" onclick="saveEdit()">💾 Save</button>
|
||||
<button class="btn btn-outline" onclick="closeEdit()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Password Modal -->
|
||||
<div class="modal-overlay" id="addModal">
|
||||
<div class="modal-box">
|
||||
<h3>➕ New Password</h3>
|
||||
<label>Website / App</label><input type="text" id="addSite" placeholder="example.com">
|
||||
<label style="margin-top:.6rem">Username / Email (optional)</label><input type="text" id="addUsername" placeholder="user@example.com">
|
||||
<label style="margin-top:.6rem">Password</label>
|
||||
<div style="position:relative">
|
||||
<input type="password" id="addPassword" placeholder="Password" style="width:100%;padding-right:40px" oninput="checkAddStrength()">
|
||||
<button type="button" onclick="openGen()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.1rem;line-height:1" title="Generate">🎲</button>
|
||||
</div>
|
||||
<div class="strength-bar s0" id="addStrengthBar" style="margin-top:0.2rem;"></div>
|
||||
<label style="margin-top:.6rem">Folder</label>
|
||||
<select id="addFolder" style="width:100%;margin-bottom:.4rem"></select>
|
||||
<div style="display:flex;gap:.5rem;margin-top:1rem">
|
||||
<button class="btn" id="addEntryBtn" onclick="addEntry()">💾 Save</button>
|
||||
<button class="btn btn-outline" onclick="closeAdd()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating action buttons -->
|
||||
<button class="fab" onclick="openAdd()" title="Add password">+</button>
|
||||
<button class="fab fab-trash" id="trashBtn" onclick="toggleTrash()" title="Trash">🗑️</button>
|
||||
<div class="vault">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.6rem">
|
||||
<h1 style="margin-bottom:0">🔐 Vault <span>XAMPP</span></h1>
|
||||
<button class="btn btn-outline btn-xs" onclick="toggleTheme()" title="Toggle theme">🌓</button>
|
||||
</div>
|
||||
|
||||
<!-- Auth Section -->
|
||||
<div id="authSection" class="auth-section">
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" onclick="switchTab('login')">Login</button>
|
||||
<button class="auth-tab" onclick="switchTab('register')">Register</button>
|
||||
</div>
|
||||
<div id="loginForm">
|
||||
<div class="input-group">
|
||||
<input type="text" id="loginUsername" placeholder="Username" autocomplete="off">
|
||||
<input type="password" id="loginPassword" placeholder="Master Password" autocomplete="off">
|
||||
<button class="btn" id="loginBtn" onclick="login()">🔓 Unlock</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="loginWithPasskey()" title="Use passkey" style="white-space:nowrap">🔐 Passkey</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="registerForm" class="hidden">
|
||||
<div class="input-group">
|
||||
<input type="text" id="regUsername" placeholder="Username (min 3)" autocomplete="off">
|
||||
<input type="password" id="regPassword" placeholder="Password (min 8)" autocomplete="off" oninput="checkRegStrength()">
|
||||
<button class="btn" id="registerBtn" onclick="register()">✨ Create</button>
|
||||
</div>
|
||||
<div class="strength-bar s0" id="regStrengthBar" style="margin-top:0.4rem;margin-bottom:0.2rem;flex-basis:100%"></div>
|
||||
</div>
|
||||
<button class="auth-tab is-active" data-tab="login">Login</button>
|
||||
<button class="auth-tab" data-tab="register">Register</button>
|
||||
</div>
|
||||
|
||||
<!-- Vault Section -->
|
||||
<div id="vaultSection" class="hidden">
|
||||
<!-- Top bar -->
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:.5rem;align-items:center;flex-wrap:wrap;gap:.4rem">
|
||||
<span class="status-badge" id="connectionStatus">🟢 Connected</span>
|
||||
<span id="entryCount" style="color:var(--text2);font-size:.8rem"></span>
|
||||
<div style="display:flex;gap:.4rem;align-items:center">
|
||||
<span id="currentUser" style="color:var(--text2);font-size:.8rem"></span>
|
||||
<div class="settings-dropdown">
|
||||
<button class="btn btn-outline btn-sm" id="settingsBtn" onclick="toggleSettings()">⚙️</button>
|
||||
<div class="settings-menu hidden" id="settingsMenu">
|
||||
<div class="settings-item">
|
||||
<span>Auto-lock</span>
|
||||
<select id="autoLockTimer" onchange="setAutoLock()">
|
||||
<option value="0">No lock</option>
|
||||
<option value="1">1 min</option>
|
||||
<option value="5" selected>5 min</option>
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">1 hour</option>
|
||||
<div id="authHint" class="auth-hint"></div>
|
||||
|
||||
<form id="loginForm" class="auth-form" autocomplete="off">
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input id="loginUsername" type="text" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Master password</span>
|
||||
<input id="loginPassword" type="password" required>
|
||||
</label>
|
||||
<button id="loginBtn" type="submit" class="btn btn-primary btn-block">
|
||||
<svg><use href="#i-unlock"/></svg>
|
||||
Unlock
|
||||
</button>
|
||||
<button id="passkeyBtn" type="button" class="btn btn-ghost btn-block">
|
||||
<svg><use href="#i-lock"/></svg>
|
||||
Use a passkey
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form id="registerForm" class="auth-form is-hidden" autocomplete="off">
|
||||
<label class="field">
|
||||
<span>Username <span class="hint">min 3 chars</span></span>
|
||||
<input id="regUsername" type="text" required minlength="3">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Master password <span class="hint">min 8 chars</span></span>
|
||||
<input id="regPassword" type="password" required minlength="8">
|
||||
<div class="strength-bar" id="regStrengthBar"></div>
|
||||
</label>
|
||||
<button id="registerBtn" type="submit" class="btn btn-primary btn-block">
|
||||
<svg><use href="#i-plus"/></svg>
|
||||
Create vault
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- APP SHELL (sidebar + main + slide-over) -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="appShell" class="app-shell is-hidden">
|
||||
|
||||
<!-- ---- SIDEBAR ----------------------------------------- -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<svg><use href="#i-lock"/></svg>
|
||||
<span>Vault</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button class="nav-item is-active" data-view="all">
|
||||
<svg><use href="#i-globe"/></svg>
|
||||
<span>All items</span>
|
||||
<span class="nav-count" id="countAll">0</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="favorites">
|
||||
<svg><use href="#i-star"/></svg>
|
||||
<span>Favorites</span>
|
||||
<span class="nav-count" id="countFav">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<span>Folders</span>
|
||||
<button class="icon-btn icon-btn-sm" id="addFolderBtn" title="New folder">
|
||||
<svg><use href="#i-plus"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav" id="foldersList"></nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header"><span>Tags</span></div>
|
||||
<nav class="sidebar-nav" id="tagsList"></nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header"><span>Tools</span></div>
|
||||
<nav class="sidebar-nav">
|
||||
<button class="nav-item" id="sidebarGenBtn">
|
||||
<svg><use href="#i-dice"/></svg>
|
||||
<span>Generator</span>
|
||||
</button>
|
||||
<button class="nav-item" id="sidebarExportBtn">
|
||||
<svg><use href="#i-log-out"/></svg>
|
||||
<span>Export vault</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<button class="nav-item" data-view="trash">
|
||||
<svg><use href="#i-trash"/></svg>
|
||||
<span>Trash</span>
|
||||
<span class="nav-count" id="countTrash">0</span>
|
||||
</button>
|
||||
<button class="nav-item" id="settingsBtn">
|
||||
<svg><use href="#i-settings"/></svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ---- MAIN -------------------------------------------- -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="search">
|
||||
<svg><use href="#i-search"/></svg>
|
||||
<input id="searchInput" type="search" placeholder="Search…" autocomplete="off">
|
||||
<kbd>Ctrl+K</kbd>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn is-active" id="viewCardsBtn" data-view="cards" title="Cards view">
|
||||
<svg><use href="#i-grid"/></svg>
|
||||
</button>
|
||||
<button class="view-btn" id="viewListBtn" data-view="list" title="List view">
|
||||
<svg><use href="#i-list"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="icon-btn" id="themeBtn" title="Toggle theme">
|
||||
<svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg>
|
||||
<svg class="theme-icon theme-icon-light"><use href="#i-moon"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" id="newEntryBtn">
|
||||
<svg><use href="#i-plus"/></svg>
|
||||
New
|
||||
</button>
|
||||
<div class="user-menu">
|
||||
<button class="user-chip" id="userChip">
|
||||
<span id="userName">user</span>
|
||||
</button>
|
||||
<div class="user-dropdown is-hidden" id="userDropdown">
|
||||
<button class="dropdown-item" id="lockBtn">
|
||||
<svg><use href="#i-lock"/></svg> Lock vault
|
||||
</button>
|
||||
<button class="dropdown-item" id="logoutBtn">
|
||||
<svg><use href="#i-log-out"/></svg> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="content-header">
|
||||
<h2 id="contentTitle">All items</h2>
|
||||
<span class="content-meta" id="contentMeta">0 items</span>
|
||||
</div>
|
||||
|
||||
<!-- Entry grid -->
|
||||
<div id="entryGrid" class="entry-grid"></div>
|
||||
|
||||
<!-- Empty state — illustration + message are filled dynamically -->
|
||||
<div id="emptyState" class="empty-state is-hidden">
|
||||
<svg class="empty-illustration" id="emptyIllustration"><use href="#i-empty-vault"/></svg>
|
||||
<h3 id="emptyTitle">Nothing here yet</h3>
|
||||
<p id="emptyMessage">Click <b>+ New</b> to add your first password.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
|
||||
<aside class="slideover" id="slideover">
|
||||
<header class="slideover-header">
|
||||
<h3 id="slideoverTitle">Entry</h3>
|
||||
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<div class="slideover-body" id="slideoverBody"></div>
|
||||
</aside>
|
||||
|
||||
<!-- ---- SETTINGS SLIDE-OVER ----------------------------- -->
|
||||
<aside class="slideover" id="settingsPanel">
|
||||
<header class="slideover-header">
|
||||
<h3>Settings</h3>
|
||||
<button class="icon-btn" id="settingsClose"><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<div class="slideover-body">
|
||||
<div class="slideover-field">
|
||||
<div class="slideover-field-label">Appearance</div>
|
||||
<div class="setting-row">
|
||||
<span>Theme</span>
|
||||
<select id="settingTheme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" onclick="toggleSound();event.stopPropagation();">
|
||||
<span>Sound</span>
|
||||
<div class="toggle-switch" id="soundToggleSwitch"></div>
|
||||
<div class="setting-row">
|
||||
<span>Compact action menu (⋯)</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="settingCompact">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" onclick="toggleTheme();event.stopPropagation();">
|
||||
<span>Theme</span>
|
||||
<div class="toggle-switch" id="themeToggleSwitch"></div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="toggleViewBtn();event.stopPropagation();">
|
||||
<span>👁️ View password</span>
|
||||
<div class="toggle-switch active" id="showViewBtnToggle"></div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="toggleShowEmail();event.stopPropagation();">
|
||||
<span>📧 Show email</span>
|
||||
<div class="toggle-switch active" id="showEmailToggle"></div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="if(window.PublicKeyCredential){registerPasskey()}else{toast('Passkeys not supported','error')};event.stopPropagation();">
|
||||
<span>🔐 Set up passkey</span>
|
||||
<span style="color:var(--accent);font-size:0.7rem">⇗</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="doLogout()">🔒 Lock</button>
|
||||
<div class="setting-row">
|
||||
<span>Mask usernames (ab***)</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="settingMaskUser">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Entry Form -->
|
||||
<!-- <form onsubmit="return false" class="input-group" autocomplete="off"> -->
|
||||
<!-- <input type="text" id="siteInput" placeholder="Website *" autocomplete="off"> -->
|
||||
<!-- <input type="text" id="usernameInput" placeholder="Username (optional)" autocomplete="off"> -->
|
||||
<!-- <div style="flex:1;min-width:130px;position:relative"> -->
|
||||
<!-- <input type="password" id="passwordInput" placeholder="Password *" autocomplete="new-password" style="width:100%;padding-right:40px" oninput="checkStrength()"> -->
|
||||
<!-- <button type="button" onclick="openGen()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.1rem;line-height:1" title="Generate password">🎲</button> -->
|
||||
<!-- </div> -->
|
||||
<!-- <div class="strength-bar s0" id="strengthBar" style="flex-basis:100%;margin-top:-0.3rem;margin-bottom:0.3rem"></div> -->
|
||||
<!-- <select id="addFolderSelect"></select> -->
|
||||
<!-- <button type="button" class="btn" id="addBtn" onclick="addEntry()">➕ Add</button> -->
|
||||
<!-- </form> -->
|
||||
|
||||
<!-- Toolbar with search + trash + views -->
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="🔍 Search..." oninput="searchEntries()" autocomplete="off">
|
||||
<button type="button" class="clear-search-btn" id="clearSearchBtn" onclick="clearSearch()" title="Clear search">✕</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:.4rem;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-outline btn-sm" onclick="showShortcutsHelp()" title="Shortcuts">⌨️</button>
|
||||
<div class="view-dropdown" id="viewDropdown">
|
||||
<button class="btn btn-outline btn-sm" id="viewDropdownBtn">🟫 Grid ▾</button>
|
||||
<div class="view-dropdown-menu hidden" id="viewDropdownMenu">
|
||||
<button class="view-opt" data-view="grid">🟫 Grid</button>
|
||||
<button class="view-opt" data-view="compact">📝 Compact</button>
|
||||
<button class="view-opt" data-view="list">📋 List</button>
|
||||
<button class="view-opt" data-view="table">📊 Table</button>
|
||||
<button class="view-opt" data-view="card">🃏 Card</button>
|
||||
<button class="view-opt" data-view="grouped">📂 Grouped</button>
|
||||
<button class="view-opt" data-view="detail">🔍 Detail</button>
|
||||
<div class="slideover-field">
|
||||
<div class="slideover-field-label">Security</div>
|
||||
<div class="setting-row">
|
||||
<span>Auto-lock after (minutes)</span>
|
||||
<select id="settingAutoLock">
|
||||
<option value="0">Never</option>
|
||||
<option value="1">1</option>
|
||||
<option value="5">5</option>
|
||||
<option value="15">15</option>
|
||||
<option value="30">30</option>
|
||||
<option value="60">60</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span>Ask before moving to trash</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="settingAskDelete">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span>
|
||||
Check passwords against breach database (HIBP)
|
||||
<small class="setting-hint">Only the first 5 chars of the SHA-1 are sent — your passwords never leave the machine.</small>
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="settingHIBP">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="folders-bar" id="foldersBar"></div>
|
||||
<div id="trashActions" class="hidden" style="margin-bottom:.5rem;text-align:right;">
|
||||
<button class="empty-trash-btn" onclick="emptyTrash()">🗑️ Empty Trash</button>
|
||||
</div>
|
||||
<div id="entriesContainer" class="grid-view">
|
||||
<div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">📭 No entries</div>
|
||||
</div>
|
||||
<div style="margin-top:.6rem;text-align:right">
|
||||
<button class="btn btn-outline btn-sm" onclick="showExportModal()">📤 Export</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slideover-field">
|
||||
<div class="slideover-field-label">Clipboard privacy</div>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
||||
Windows keeps copied passwords in <b>clipboard history (Win+V)</b>.
|
||||
To prevent that, disable history in <i>Settings → System → Clipboard</i>.
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-sm" id="openClipboardSettings">Why?</button>
|
||||
</div>
|
||||
|
||||
<!-- Generator Modal -->
|
||||
<div id="genModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:none;justify-content:center;align-items:center;z-index:2000">
|
||||
<div style="background:var(--bg2);border-radius:1.5rem;padding:1.5rem;min-width:320px;max-width:90%">
|
||||
<h3 style="margin-bottom:.8rem;color:var(--text)">🎲 Generator</h3>
|
||||
<div style="background:var(--input);padding:.8rem;border-radius:1rem;font-family:monospace;text-align:center;color:#4ade80;margin:.6rem 0;word-break:break-all" id="genPreview">Click Generate</div>
|
||||
<div style="display:flex;align-items:center;gap:.6rem;margin:.8rem 0">
|
||||
<span>Length:</span>
|
||||
<input type="range" id="pwdLen" min="8" max="64" value="16" oninput="onLenChange()" style="flex:1">
|
||||
<span id="lenVal" style="background:var(--input);padding:.2rem .6rem;border-radius:1rem;min-width:30px;text-align:center">16</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.5rem;margin:.5rem 0">
|
||||
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useUpper" checked onchange="genPwd()"> A-Z</label>
|
||||
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useLower" checked onchange="genPwd()"> a-z</label>
|
||||
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useNum" checked onchange="genPwd()"> 0-9</label>
|
||||
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useSym" checked onchange="genPwd()"> !@#$</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.3rem;margin-top:0.8rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm" onclick="genPreset(16,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 16</button>
|
||||
<button class="btn btn-sm" onclick="genPreset(20,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 20</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="genPreset(32,'upper+lower+num+sym')" style="flex:1;min-width:70px">🛡️ Paranoid 32</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:.4rem;margin-top:.6rem">
|
||||
<button class="btn" onclick="genPwd()" style="flex:1">🔄</button>
|
||||
<button class="btn" onclick="useGen()" style="flex:1">✅ Use</button>
|
||||
<button class="btn btn-outline" onclick="closeGen()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slideover-field">
|
||||
<div class="slideover-field-label">Export</div>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
||||
Download all your passwords as JSON. Requires your master password — file is plaintext.
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-sm" id="exportBtn">
|
||||
<svg><use href="#i-log-out"/></svg> Export vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<div class="slideover-field">
|
||||
<div class="slideover-field-label">Account</div>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px">
|
||||
Signed in as <b id="settingUser"></b>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MODAL: Re-auth (for export and other sensitive ops) -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="reauthModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel modal-panel-sm">
|
||||
<header class="modal-header">
|
||||
<h3 id="reauthTitle">Confirm master password</h3>
|
||||
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<form id="reauthForm" class="modal-body" autocomplete="off">
|
||||
<p style="margin:0 0 12px;color:var(--text-dim);font-size:13px" id="reauthMessage">
|
||||
This action requires your master password.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>Master password</span>
|
||||
<input id="reauthPassword" type="password" required autofocus>
|
||||
</label>
|
||||
</form>
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-close>Cancel</button>
|
||||
<button type="submit" form="reauthForm" class="btn btn-primary" id="reauthConfirm">
|
||||
<svg><use href="#i-check"/></svg> Confirm
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MODAL: New / Edit Entry -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="entryModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel">
|
||||
<header class="modal-header">
|
||||
<h3 id="entryModalTitle">New entry</h3>
|
||||
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<form id="entryForm" class="modal-body" autocomplete="off">
|
||||
<input type="hidden" id="entryId">
|
||||
<label class="field">
|
||||
<span>Website / App</span>
|
||||
<input id="entrySite" type="text" required placeholder="example.com">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Username / Email</span>
|
||||
<input id="entryUsername" type="text" placeholder="me@example.com">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<div class="input-with-action">
|
||||
<input id="entryPassword" type="password" required placeholder="••••••••">
|
||||
<button type="button" class="icon-btn" id="entryPwToggle"><svg><use href="#i-eye"/></svg></button>
|
||||
<button type="button" class="icon-btn" id="entryPwGen" title="Generate"><svg><use href="#i-dice"/></svg></button>
|
||||
</div>
|
||||
<div class="strength-bar" id="entryStrengthBar"></div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Folder</span>
|
||||
<select id="entryFolder"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Tags <span class="hint">enter to add</span></span>
|
||||
<div class="chip-input" id="entryTagsInput">
|
||||
<input id="entryTagsField" type="text" placeholder="add a tag…" autocomplete="off">
|
||||
</div>
|
||||
<input type="hidden" id="entryTags">
|
||||
</label>
|
||||
</form>
|
||||
<footer class="modal-footer">
|
||||
<button class="btn btn-ghost" data-close>Cancel</button>
|
||||
<button class="btn btn-primary" id="entrySaveBtn">
|
||||
<svg><use href="#i-check"/></svg> Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MODAL: Password Generator -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="genModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel modal-panel-sm">
|
||||
<header class="modal-header">
|
||||
<h3>Password generator</h3>
|
||||
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="gen-preview" id="genPreview">click regenerate</div>
|
||||
<label class="field">
|
||||
<span>Length <b id="genLenLabel">16</b></span>
|
||||
<input id="genLen" type="range" min="6" max="128" value="16">
|
||||
</label>
|
||||
<div class="gen-options">
|
||||
<label class="check"><input type="checkbox" id="genUpper" checked><span>A-Z</span></label>
|
||||
<label class="check"><input type="checkbox" id="genLower" checked><span>a-z</span></label>
|
||||
<label class="check"><input type="checkbox" id="genNum" checked><span>0-9</span></label>
|
||||
<label class="check"><input type="checkbox" id="genSym" checked><span>!@#</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<button class="btn btn-ghost" id="genRegen"><svg><use href="#i-rotate-ccw"/></svg> Regenerate</button>
|
||||
<button class="btn btn-ghost" id="genCopy"><svg><use href="#i-copy"/></svg> Copy</button>
|
||||
<button class="btn btn-primary" id="genUse"><svg><use href="#i-check"/></svg> Use</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CONFIRM / PROMPT MODAL (replaces native confirm/prompt) -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="confirmModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-confirm-cancel></div>
|
||||
<div class="modal-panel modal-panel-sm">
|
||||
<header class="modal-header">
|
||||
<h3 id="confirmTitle">Confirm</h3>
|
||||
<button class="icon-btn" data-confirm-cancel><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<form id="confirmForm" class="modal-body">
|
||||
<p id="confirmMessage" style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
|
||||
Are you sure?
|
||||
</p>
|
||||
<label class="field is-hidden" id="confirmInputField">
|
||||
<input id="confirmInput" type="text" autocomplete="off">
|
||||
</label>
|
||||
</form>
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-confirm-cancel id="confirmCancelBtn">Cancel</button>
|
||||
<button type="submit" form="confirmForm" class="btn btn-primary" id="confirmOkBtn">
|
||||
<svg><use href="#i-check"/></svg> Confirm
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- COMMAND PALETTE (Cmd+K) — phase 3, shell only for now -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="cmdPalette" class="cmd-palette is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="cmd-panel">
|
||||
<div class="cmd-input">
|
||||
<svg><use href="#i-command"/></svg>
|
||||
<input id="cmdInput" type="text" placeholder="Type a command or search…">
|
||||
</div>
|
||||
<div class="cmd-results" id="cmdResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -82,6 +82,9 @@ const state = {
|
||||
compactActions: localStorage.getItem('compactActions') === '1', // default false
|
||||
viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list'
|
||||
checked: new Set(), // entry IDs checked for batch operations
|
||||
hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF
|
||||
// entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked)
|
||||
hibpResults: new Map(),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
@@ -174,6 +177,90 @@ async function api(path, opts) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HIBP — Have I Been Pwned breach check (k-anonymity)
|
||||
// ============================================================
|
||||
//
|
||||
// HIBP's range API exposes pwned password counts without ever seeing the
|
||||
// password (or even its full hash):
|
||||
// 1. Client computes SHA-1 of the password.
|
||||
// 2. Client sends ONLY the first 5 hex chars to api.pwnedpasswords.com/range/XXXXX
|
||||
// 3. Server returns up to ~500 suffixes (35 chars each) with counts.
|
||||
// 4. Client searches the response for its own suffix locally.
|
||||
//
|
||||
// This means the network observer (and HIBP itself) sees only the 5-char
|
||||
// prefix — which matches ~3,000 of the ~half-billion known pwned passwords.
|
||||
// Information leakage is bounded by design.
|
||||
//
|
||||
// Toggle is OFF by default. When enabled, all entries are checked once
|
||||
// after vault load, then individual entries are re-checked when the user
|
||||
// edits the password. Results cached in state.hibpResults keyed by entry id.
|
||||
|
||||
async function sha1Hex(text) {
|
||||
const buf = new TextEncoder().encode(text);
|
||||
const hashBuf = await crypto.subtle.digest('SHA-1', buf);
|
||||
const bytes = new Uint8Array(hashBuf);
|
||||
let hex = '';
|
||||
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
||||
return hex.toUpperCase();
|
||||
}
|
||||
|
||||
// Returns the breach count (0 if not found, >0 if pwned). Throws on
|
||||
// network failure — caller decides whether to silently skip or alert.
|
||||
async function hibpCheckPassword(plaintext) {
|
||||
if (!plaintext) return 0;
|
||||
const hash = await sha1Hex(plaintext);
|
||||
const prefix = hash.substring(0, 5);
|
||||
const suffix = hash.substring(5);
|
||||
|
||||
const resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, {
|
||||
// Padding mitigates side-channel attacks where an observer counts
|
||||
// bytes in the response to narrow down the prefix queried.
|
||||
headers: { 'Add-Padding': 'true' },
|
||||
});
|
||||
if (!resp.ok) throw new Error('HIBP HTTP ' + resp.status);
|
||||
const body = await resp.text();
|
||||
// Body lines: "SUFFIX:COUNT\r\n" — search for our suffix.
|
||||
for (const line of body.split('\n')) {
|
||||
const colonAt = line.indexOf(':');
|
||||
if (colonAt <= 0) continue;
|
||||
if (line.substring(0, colonAt).trim() === suffix) {
|
||||
return parseInt(line.substring(colonAt + 1).trim(), 10) || 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Batch-check every entry currently in state.entries. Awaits all in
|
||||
// parallel but with a small concurrency cap so we don't hammer HIBP
|
||||
// or trip browser connection limits. Mutates state.hibpResults and
|
||||
// re-renders to show the new badges.
|
||||
async function hibpCheckAllEntries() {
|
||||
if (!state.hibpEnabled || !state.entries.length) return;
|
||||
const CONCURRENCY = 6;
|
||||
const queue = state.entries.slice();
|
||||
const workers = [];
|
||||
|
||||
for (let w = 0; w < CONCURRENCY; w++) {
|
||||
workers.push((async () => {
|
||||
while (queue.length) {
|
||||
const entry = queue.shift();
|
||||
try {
|
||||
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
|
||||
if (pwd === '[ERROR]') continue;
|
||||
const count = await hibpCheckPassword(pwd);
|
||||
state.hibpResults.set(entry.id, count);
|
||||
} catch (e) {
|
||||
// Network or decrypt failure: skip silently. Will retry
|
||||
// next time the user opens the vault.
|
||||
}
|
||||
}
|
||||
})());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
render();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// KDF MIGRATION (PBKDF2 100k → 600k re-encryption)
|
||||
// ============================================================
|
||||
@@ -942,6 +1029,19 @@ function renderCard(e) {
|
||||
chip.appendChild(el('span', null, t));
|
||||
meta.appendChild(chip);
|
||||
});
|
||||
// HIBP pwned badge — only shown when the user enabled HIBP and the
|
||||
// background scan completed with count > 0 for this entry.
|
||||
const pwnedCount = state.hibpResults.get(e.id);
|
||||
if (state.hibpEnabled && pwnedCount && pwnedCount > 0) {
|
||||
const chip = el('span', {
|
||||
class: 'entry-chip is-pwned',
|
||||
title: 'This password appeared in ' + pwnedCount.toLocaleString() +
|
||||
' known data breaches. Consider changing it.',
|
||||
});
|
||||
chip.appendChild(icon('i-alert'));
|
||||
chip.appendChild(el('span', null, 'Pwned'));
|
||||
meta.appendChild(chip);
|
||||
}
|
||||
card.appendChild(meta);
|
||||
|
||||
return card;
|
||||
@@ -2086,6 +2186,7 @@ function openSettings() {
|
||||
$('#settingAskDelete').checked = state.askBeforeDelete;
|
||||
$('#settingCompact').checked = state.compactActions;
|
||||
$('#settingMaskUser').checked = state.maskUsernames;
|
||||
$('#settingHIBP').checked = state.hibpEnabled;
|
||||
$('#settingUser').textContent = state.username;
|
||||
$('#settingsPanel').classList.add('is-open');
|
||||
}
|
||||
@@ -2159,6 +2260,9 @@ async function enterApp() {
|
||||
await loadEntries();
|
||||
render();
|
||||
resetAutoLock();
|
||||
// Fire-and-forget HIBP scan if the user opted in. Runs in background,
|
||||
// re-renders when done to show badges.
|
||||
if (state.hibpEnabled) hibpCheckAllEntries();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -2351,6 +2455,18 @@ async function init() {
|
||||
localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0');
|
||||
render();
|
||||
});
|
||||
$('#settingHIBP').addEventListener('change', e => {
|
||||
state.hibpEnabled = e.target.checked;
|
||||
localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0');
|
||||
if (state.hibpEnabled) {
|
||||
toast('Checking passwords against breach database…');
|
||||
hibpCheckAllEntries();
|
||||
} else {
|
||||
state.hibpResults.clear();
|
||||
render();
|
||||
toast('Breach check disabled');
|
||||
}
|
||||
});
|
||||
$('#openClipboardSettings').addEventListener('click', () => {
|
||||
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user