05/18 Enhanced codes and functionalities 7
This commit is contained in:
@@ -52,13 +52,15 @@ passkeeper/
|
|||||||
│ │ ├── shared_item.py # ECDH-encrypted cross-user shares; enc_name/iv_name
|
│ │ ├── shared_item.py # ECDH-encrypted cross-user shares; enc_name/iv_name
|
||||||
│ │ ├── emergency_access.py # State machine
|
│ │ ├── emergency_access.py # State machine
|
||||||
│ │ ├── recovery_challenge.py # Server-side recovery challenge (multi-worker safe)
|
│ │ ├── recovery_challenge.py # Server-side recovery challenge (multi-worker safe)
|
||||||
|
│ │ ├── webauthn_credential.py # Passkey / WebAuthn credentials (one row per key)
|
||||||
│ │ └── audit_log.py
|
│ │ └── audit_log.py
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── auth.py # Register, login, MFA, logout, refresh, change-password, recovery
|
│ │ ├── auth.py # Register, login, MFA, logout, refresh, change-password, recovery
|
||||||
│ │ ├── vault.py # CRUD + GET /export + POST /import
|
│ │ ├── vault.py # CRUD + GET /export + POST /import
|
||||||
│ │ ├── folders.py
|
│ │ ├── folders.py
|
||||||
│ │ ├── sharing.py
|
│ │ ├── sharing.py
|
||||||
│ │ └── emergency.py
|
│ │ ├── emergency.py
|
||||||
|
│ │ └── webauthn.py # Passkey registration, authentication, credential management
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt,
|
│ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt,
|
||||||
│ │ # TOTP replay helpers (is_totp_code_used / mark_totp_code_used)
|
│ │ # TOTP replay helpers (is_totp_code_used / mark_totp_code_used)
|
||||||
@@ -101,7 +103,8 @@ passkeeper/
|
|||||||
│ ├── d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py
|
│ ├── d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py
|
||||||
│ ├── e5f6a7b8c9d0_add_recovery_challenges_table.py
|
│ ├── e5f6a7b8c9d0_add_recovery_challenges_table.py
|
||||||
│ ├── f6a7b8c9d0e1_add_totp_used_codes_table.py # TOTP replay prevention
|
│ ├── f6a7b8c9d0e1_add_totp_used_codes_table.py # TOTP replay prevention
|
||||||
│ └── g7h8i9j0k1l2_encrypt_shared_item_name.py # enc_name/iv_name on shared_items
|
│ ├── g7h8i9j0k1l2_encrypt_shared_item_name.py # enc_name/iv_name on shared_items
|
||||||
|
│ └── h8i9j0k1l2m3_add_webauthn_credentials_table.py # Passkey / WebAuthn credentials
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── reencrypt_totp_secrets.py
|
│ ├── reencrypt_totp_secrets.py
|
||||||
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate
|
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate
|
||||||
@@ -190,6 +193,21 @@ CREATE TABLE recovery_challenges (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- (folders, token_blacklist, emergency_access, audit_logs — standard schemas)
|
-- (folders, token_blacklist, emergency_access, audit_logs — standard schemas)
|
||||||
|
|
||||||
|
-- WebAuthn / Passkey Credentials
|
||||||
|
CREATE TABLE webauthn_credentials (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
credential_id VARCHAR(512) NOT NULL UNIQUE, -- base64url authenticator credential ID
|
||||||
|
public_key TEXT NOT NULL, -- COSE public key, base64url
|
||||||
|
sign_count BIGINT NOT NULL DEFAULT 0, -- clone detection counter
|
||||||
|
transports VARCHAR(255), -- JSON list e.g. '["internal","hybrid"]'
|
||||||
|
aaguid VARCHAR(64), -- authenticator AAGUID
|
||||||
|
name VARCHAR(128) NOT NULL DEFAULT 'Passkey', -- user-assigned label
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
last_used_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -206,6 +224,7 @@ CREATE TABLE recovery_challenges (
|
|||||||
| `e5f6a7b8c9d0` | Add recovery_challenges table |
|
| `e5f6a7b8c9d0` | Add recovery_challenges table |
|
||||||
| `f6a7b8c9d0e1` | Add totp_used_codes table (TOTP replay prevent.) |
|
| `f6a7b8c9d0e1` | Add totp_used_codes table (TOTP replay prevent.) |
|
||||||
| `g7h8i9j0k1l2` | Add enc_name/iv_name to shared_items |
|
| `g7h8i9j0k1l2` | Add enc_name/iv_name to shared_items |
|
||||||
|
| `h8i9j0k1l2m3` | Add webauthn_credentials table (passkeys) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -221,6 +240,7 @@ CREATE TABLE recovery_challenges (
|
|||||||
- **Argon2id:** double-hashes `authHash` server-side; transparently rehashes on login if parameters are upgraded
|
- **Argon2id:** double-hashes `authHash` server-side; transparently rehashes on login if parameters are upgraded
|
||||||
- **JWT:** HS256, 15 min access / 7 day refresh, JTI blacklisted on logout
|
- **JWT:** HS256, 15 min access / 7 day refresh, JTI blacklisted on logout
|
||||||
- **MFA:** TOTP secret AES-256-GCM encrypted at rest; each code is single-use (replay prevented via `totp_used_codes` table, 120s TTL)
|
- **MFA:** TOTP secret AES-256-GCM encrypted at rest; each code is single-use (replay prevented via `totp_used_codes` table, 120s TTL)
|
||||||
|
- **Passkeys / WebAuthn:** server authentication via FIDO2; ZK model preserved — WebAuthn proves identity to the server but the vault key is still derived from the master password client-side; `sign_count` updated on every assertion for clone detection
|
||||||
- **Sharing:** ECDH P-256 zero-knowledge re-encryption; item name also encrypted with shared key
|
- **Sharing:** ECDH P-256 zero-knowledge re-encryption; item name also encrypted with shared key
|
||||||
- **Password generator:** fully CSPRNG (`_cryptoRandInt` rejection-sampling)
|
- **Password generator:** fully CSPRNG (`_cryptoRandInt` rejection-sampling)
|
||||||
- **Decrypted vault data:** `chrome.storage.session` only — never to disk
|
- **Decrypted vault data:** `chrome.storage.session` only — never to disk
|
||||||
@@ -252,6 +272,64 @@ Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'sessi
|
|||||||
|
|
||||||
## Key Implementation Details
|
## Key Implementation Details
|
||||||
|
|
||||||
|
### WebAuthn / Passkey
|
||||||
|
|
||||||
|
**Library:** `py-webauthn` (`webauthn>=2.0`). Installed via `pip install webauthn`.
|
||||||
|
|
||||||
|
**Config** (`.env` / environment):
|
||||||
|
```
|
||||||
|
WEBAUTHN_RP_ID=pwkeeper.ngodanguyen.tech # effective domain, no scheme/port
|
||||||
|
WEBAUTHN_RP_NAME=PassKeeper
|
||||||
|
WEBAUTHN_ORIGINS=https://pwkeeper.ngodanguyen.tech # comma-separated in env
|
||||||
|
```
|
||||||
|
In development: `WEBAUTHN_RP_ID=localhost`, `WEBAUTHN_ORIGINS=http://localhost:5000`.
|
||||||
|
|
||||||
|
**Flows:**
|
||||||
|
|
||||||
|
*Registration* (requires active JWT — user must be logged in):
|
||||||
|
```
|
||||||
|
POST /api/webauthn/register/begin → PublicKeyCredentialCreationOptions
|
||||||
|
POST /api/webauthn/register/complete → verifies attestation, stores credential
|
||||||
|
```
|
||||||
|
|
||||||
|
*Authentication* (unauthenticated — replaces password login):
|
||||||
|
```
|
||||||
|
POST /api/webauthn/authenticate/begin → PublicKeyCredentialRequestOptions
|
||||||
|
POST /api/webauthn/authenticate/complete → verifies assertion → { access_token, refresh_token, enc_key_salt }
|
||||||
|
```
|
||||||
|
Client still prompts for master password after successful assertion to derive vault key.
|
||||||
|
|
||||||
|
*Management* (requires JWT):
|
||||||
|
```
|
||||||
|
GET /api/webauthn/credentials → list registered passkeys
|
||||||
|
PATCH /api/webauthn/credentials/<id> → rename a passkey
|
||||||
|
DELETE /api/webauthn/credentials/<id> → remove a passkey
|
||||||
|
```
|
||||||
|
|
||||||
|
**Challenge storage:** challenge bytes are stored in the Flask session (signed cookie, `SECRET_KEY`). No DB row needed. Challenge is consumed (`session.pop`) on the `/complete` call.
|
||||||
|
|
||||||
|
**Clone detection:** `sign_count` is read before verification and updated after. `py-webauthn` raises on a decreasing counter.
|
||||||
|
|
||||||
|
**ZK preserved:** a WebAuthn assertion authenticates the user to the *server* only. The vault key `PBKDF2(masterPassword, enc_key_salt, 600k)` is never sent to or derivable by the server. After a passkey login the client must still enter the master password to unlock the vault.
|
||||||
|
|
||||||
|
**Client-side (`PasskeyAuth` in `auth.js`):**
|
||||||
|
- `loginWithPasskey(email)` — begin → `navigator.credentials.get()` → complete → stores tokens → redirects to `/vault`
|
||||||
|
- `registerPasskey(name)` — begin → `navigator.credentials.create()` → complete
|
||||||
|
- `_bufToB64url` / `_b64urlToBuf` — ArrayBuffer ↔ base64url conversion helpers
|
||||||
|
- `_credentialToJson(cred)` — serialises `PublicKeyCredential` for the server
|
||||||
|
|
||||||
|
**Settings UI (`vault.js` `loadPasskeys()`):** renders registered passkeys list with rename/delete; wires "Add passkey" button; hides the section if `window.PublicKeyCredential` is absent.
|
||||||
|
|
||||||
|
**`auth.js` login wiring:** "Sign in with Passkey" button on `login.html` calls `PasskeyAuth.loginWithPasskey(email)`. On success, stores tokens and navigates to `/vault` — unlock overlay fires if master password field was empty.
|
||||||
|
|
||||||
|
**Authenticator attachment:** currently `PLATFORM` (biometrics / device passkey). To support roaming authenticators (YubiKey, phone-as-key), remove or change `authenticator_attachment` in `register_begin`.
|
||||||
|
|
||||||
|
**Exception names** (`webauthn>=2.0`):
|
||||||
|
- `InvalidRegistrationResponse` — use in `register_complete`
|
||||||
|
- `InvalidAuthenticationResponse` — use in `authenticate_complete`
|
||||||
|
- `InvalidCBORData` — malformed CBOR attestation
|
||||||
|
- NOT `InvalidAuthenticatorResponse` (does not exist)
|
||||||
|
|
||||||
### TOTP replay prevention
|
### TOTP replay prevention
|
||||||
|
|
||||||
Every accepted TOTP code is recorded in `totp_used_codes` (user_id + code, 120s TTL). A second attempt with the same code within that window returns `400 Verification code already used`. Applies to: `mfa_enable`, `mfa_disable`, `mfa_verify`, `mfa_backup_codes_regenerate`. The table is pruned by the APScheduler cleanup job alongside `token_blacklist` and `recovery_challenges`.
|
Every accepted TOTP code is recorded in `totp_used_codes` (user_id + code, 120s TTL). A second attempt with the same code within that window returns `400 Verification code already used`. Applies to: `mfa_enable`, `mfa_disable`, `mfa_verify`, `mfa_backup_codes_regenerate`. The table is pruned by the APScheduler cleanup job alongside `token_blacklist` and `recovery_challenges`.
|
||||||
@@ -433,6 +511,9 @@ Audit log details **never** contain plaintext item names, shared item names, or
|
|||||||
| `sharing.py` | `sharing_keys.create/update` | ECDH key setup |
|
| `sharing.py` | `sharing_keys.create/update` | ECDH key setup |
|
||||||
| `sharing.py` | `shared_item.create/delete/accept` | Sharing (detail: type + id) |
|
| `sharing.py` | `shared_item.create/delete/accept` | Sharing (detail: type + id) |
|
||||||
| `emergency.py` | `emergency_access.*` | All EA state transitions |
|
| `emergency.py` | `emergency_access.*` | All EA state transitions |
|
||||||
|
| `webauthn.py` | `webauthn.register` | Passkey registered |
|
||||||
|
| `webauthn.py` | `webauthn.auth_success` / `webauthn.auth_failed` | Passkey login attempt |
|
||||||
|
| `webauthn.py` | `webauthn.rename` / `webauthn.delete` | Credential management |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -466,6 +547,14 @@ GET|POST /api/emergency
|
|||||||
DELETE /api/emergency/<id>
|
DELETE /api/emergency/<id>
|
||||||
POST /api/emergency/<id>/accept|provide|request|deny
|
POST /api/emergency/<id>/accept|provide|request|deny
|
||||||
GET /api/emergency/<id>/vault
|
GET /api/emergency/<id>/vault
|
||||||
|
|
||||||
|
POST /api/webauthn/register/begin # start passkey registration (JWT required)
|
||||||
|
POST /api/webauthn/register/complete # finish registration + store credential
|
||||||
|
POST /api/webauthn/authenticate/begin # start passkey login (unauthenticated)
|
||||||
|
POST /api/webauthn/authenticate/complete # verify assertion → tokens + enc_key_salt
|
||||||
|
GET /api/webauthn/credentials # list registered passkeys (JWT required)
|
||||||
|
PATCH /api/webauthn/credentials/<id> # rename a passkey
|
||||||
|
DELETE /api/webauthn/credentials/<id> # remove a passkey
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -497,6 +586,7 @@ pymysql>=1.1 argon2-cffi>=23.1 pyjwt>=2.8 python-dotenv>=1.0
|
|||||||
gunicorn>=21.0 pyotp>=2.9.0 qrcode[pil]>=7.4.2
|
gunicorn>=21.0 pyotp>=2.9.0 qrcode[pil]>=7.4.2
|
||||||
cryptography>=42.0 # TOTP secret encryption
|
cryptography>=42.0 # TOTP secret encryption
|
||||||
redis>=5.0 # Rate-limit storage (required in production)
|
redis>=5.0 # Rate-limit storage (required in production)
|
||||||
|
webauthn>=2.0 # Passkey / WebAuthn (py-webauthn)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ A self-hosted, zero-knowledge password manager — web app and Chrome/Firefox ex
|
|||||||
- **Security dashboard** — weak / reused / old / **no 2FA saved** / **HaveIBeenPwned breach check** (k-anonymity — passwords never transmitted)
|
- **Security dashboard** — weak / reused / old / **no 2FA saved** / **HaveIBeenPwned breach check** (k-anonymity — passwords never transmitted)
|
||||||
- **Import / Export** — encrypted JSON backup; CSV export (plaintext, handle carefully); import from Chrome, Bitwarden, and 1Password CSV formats (RFC 4180 compliant parser)
|
- **Import / Export** — encrypted JSON backup; CSV export (plaintext, handle carefully); import from Chrome, Bitwarden, and 1Password CSV formats (RFC 4180 compliant parser)
|
||||||
- **Account MFA** — TOTP-based login (Google Authenticator / Authy); single-use code enforcement prevents replay attacks
|
- **Account MFA** — TOTP-based login (Google Authenticator / Authy); single-use code enforcement prevents replay attacks
|
||||||
|
- **Passkeys / WebAuthn** — register device biometrics or hardware keys as a sign-in method; master password still required to unlock vault (zero-knowledge preserved); manage passkeys in Account Settings
|
||||||
- **Master password change** — atomic zero-knowledge re-encryption of entire vault including item names
|
- **Master password change** — atomic zero-knowledge re-encryption of entire vault including item names
|
||||||
- **Account recovery** — 128-bit recovery code; server never stores it; challenge-response proof prevents forgery
|
- **Account recovery** — 128-bit recovery code; server never stores it; challenge-response proof prevents forgery
|
||||||
- **Audit log** — server-side trail of all create/edit/delete/import/export actions; no plaintext names ever logged
|
- **Audit log** — server-side trail of all create/edit/delete/import/export actions; no plaintext names ever logged
|
||||||
@@ -76,6 +77,7 @@ Sharing: ECDH(Alice_priv, Bob_pub) ──► sharedKey ──► AES-256-GCM(en
|
|||||||
- **folder_id ownership** — all create/update/import operations validate folder belongs to current user
|
- **folder_id ownership** — all create/update/import operations validate folder belongs to current user
|
||||||
- **Audit log privacy** — item names and shared item names never appear in server-side audit logs
|
- **Audit log privacy** — item names and shared item names never appear in server-side audit logs
|
||||||
- **Extension fingerprinting** — `web_accessible_resources: []` blocks external pages from probing extension files
|
- **Extension fingerprinting** — `web_accessible_resources: []` blocks external pages from probing extension files
|
||||||
|
- **Passkey / WebAuthn** — FIDO2 assertion proves identity to the server without a password; vault key still derived from master password client-side; `sign_count` updated on each use for clone detection
|
||||||
|
|
||||||
A database breach exposes only encrypted ciphertext. The server cannot read vault names, passwords, tags, or shared item names.
|
A database breach exposes only encrypted ciphertext. The server cannot read vault names, passwords, tags, or shared item names.
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ A database breach exposes only encrypted ciphertext. The server cannot read vaul
|
|||||||
| Backend | Python 3.12, Flask 3.x |
|
| Backend | Python 3.12, Flask 3.x |
|
||||||
| Database | MySQL 8.x |
|
| Database | MySQL 8.x |
|
||||||
| Frontend | Vanilla JS, Web Crypto API, Jinja2 |
|
| Frontend | Vanilla JS, Web Crypto API, Jinja2 |
|
||||||
| Auth | Argon2id + PBKDF2 + JWT (HS256) |
|
| Auth | Argon2id + PBKDF2 + JWT (HS256) + WebAuthn (FIDO2) |
|
||||||
| Encryption | AES-256-GCM (client-side) |
|
| Encryption | AES-256-GCM (client-side) |
|
||||||
| Extension | Chrome MV3 / Firefox MV2 |
|
| Extension | Chrome MV3 / Firefox MV2 |
|
||||||
| Web server | Nginx + Gunicorn + systemd |
|
| Web server | Nginx + Gunicorn + systemd |
|
||||||
@@ -198,6 +200,9 @@ flask db upgrade && sudo systemctl reload passkeeper
|
|||||||
| `TOTP_ENCRYPTION_KEY` | Server-side AES key for TOTP secrets (64-char hex) |
|
| `TOTP_ENCRYPTION_KEY` | Server-side AES key for TOTP secrets (64-char hex) |
|
||||||
| `RATELIMIT_STORAGE_URI` | Redis URI — required in production (`redis://127.0.0.1:6379/0`) |
|
| `RATELIMIT_STORAGE_URI` | Redis URI — required in production (`redis://127.0.0.1:6379/0`) |
|
||||||
| `CORS_ORIGINS` | Allowed origins (`*` in dev, domain in prod) |
|
| `CORS_ORIGINS` | Allowed origins (`*` in dev, domain in prod) |
|
||||||
|
| `WEBAUTHN_RP_ID` | Passkey relying party ID — effective domain, no scheme (`pwkeeper.ngodanguyen.tech`) |
|
||||||
|
| `WEBAUTHN_RP_NAME` | Passkey relying party display name (`PassKeeper`) |
|
||||||
|
| `WEBAUTHN_ORIGINS` | Comma-separated allowed origins for WebAuthn (`https://pwkeeper.ngodanguyen.tech`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -249,6 +254,12 @@ All vault/folder endpoints require `Authorization: Bearer <access_token>`.
|
|||||||
| POST | `/api/emergency` | Create emergency access grant |
|
| POST | `/api/emergency` | Create emergency access grant |
|
||||||
| POST | `/api/auth/change-password` | Atomic vault re-encryption |
|
| POST | `/api/auth/change-password` | Atomic vault re-encryption |
|
||||||
| POST | `/api/auth/recover` | Account recovery (one-time) |
|
| POST | `/api/auth/recover` | Account recovery (one-time) |
|
||||||
|
| POST | `/api/webauthn/register/begin` | Start passkey registration |
|
||||||
|
| POST | `/api/webauthn/register/complete` | Finish passkey registration |
|
||||||
|
| POST | `/api/webauthn/authenticate/begin` | Start passkey login |
|
||||||
|
| POST | `/api/webauthn/authenticate/complete` | Complete passkey login → tokens |
|
||||||
|
| GET | `/api/webauthn/credentials` | List registered passkeys |
|
||||||
|
| DELETE | `/api/webauthn/credentials/<id>` | Remove a passkey |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2463,3 +2463,90 @@ html.sidebar-open {
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Vault health notifications ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Sidebar badge — shows issue count on Security link */
|
||||||
|
.sidebar-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: #c0392b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-badge.badge-warn {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Health banner — dismissible strip above the vault list */
|
||||||
|
.health-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-banner.banner-danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-banner.banner-warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-banner.banner-ok {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-banner-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-banner-text {
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-banner-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-banner-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.6;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-banner-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
+157
-9
@@ -234,6 +234,13 @@ const Vault = (() => {
|
|||||||
renderFolderList();
|
renderFolderList();
|
||||||
renderTagList();
|
renderTagList();
|
||||||
applyCurrentFilter();
|
applyCurrentFilter();
|
||||||
|
// Reset banner dismissed flag so fresh health results are visible.
|
||||||
|
const banner = document.getElementById("health-banner");
|
||||||
|
if (banner) banner.dataset.dismissed = "0";
|
||||||
|
// Run health checks in the background — updates badge + banner without
|
||||||
|
// blocking the vault render. Results are cached so opening the Security
|
||||||
|
// tab doesn't re-run HIBP checks.
|
||||||
|
runBackgroundHealthCheck();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Failed to load vault: " + err.message, "error");
|
showToast("Failed to load vault: " + err.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -678,6 +685,141 @@ const Vault = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Background vault health checks ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Runs after every vault load. Computes weak/reused counts synchronously,
|
||||||
|
// then fires HIBP checks in parallel. Updates the Security sidebar badge
|
||||||
|
// and a dismissible top banner without requiring the user to open the
|
||||||
|
// Security tab. Results are cached so re-opening the tab skips re-checking.
|
||||||
|
|
||||||
|
let _healthCache = null; // { weak, reused, breached } — populated after first run
|
||||||
|
let _hibpRunning = false; // prevents concurrent HIBP runs
|
||||||
|
|
||||||
|
async function runBackgroundHealthCheck() {
|
||||||
|
if (_hibpRunning) return;
|
||||||
|
_hibpRunning = true;
|
||||||
|
_healthCache = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pwItems = _items.filter(
|
||||||
|
(i) => i.item_type === "password" && i.plain?.password,
|
||||||
|
);
|
||||||
|
if (!pwItems.length) {
|
||||||
|
_updateHealthUI({ weak: 0, reused: 0, breached: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous metrics — instant.
|
||||||
|
const weak = pwItems.filter((i) => {
|
||||||
|
const p = i.plain.password;
|
||||||
|
if (p.length < 10) return true;
|
||||||
|
return (
|
||||||
|
[/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p))
|
||||||
|
.length < 2
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const passCounts = {};
|
||||||
|
pwItems.forEach((i) => {
|
||||||
|
const p = i.plain.password;
|
||||||
|
(passCounts[p] = passCounts[p] || []).push(i);
|
||||||
|
});
|
||||||
|
const reused = Object.values(passCounts)
|
||||||
|
.filter((a) => a.length > 1)
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
// Update badge immediately with sync results — HIBP will update again.
|
||||||
|
_updateHealthUI({ weak: weak.length, reused: reused.length, breached: null });
|
||||||
|
|
||||||
|
// HIBP — k-anonymity, runs in parallel.
|
||||||
|
const hibpResults = await Promise.all(
|
||||||
|
pwItems.map(async (item) => ({
|
||||||
|
item,
|
||||||
|
count: await checkHibp(item.plain.password),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const breachedItems = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
|
||||||
|
|
||||||
|
_healthCache = {
|
||||||
|
weak: weak.length,
|
||||||
|
reused: reused.length,
|
||||||
|
breached: breachedItems.length,
|
||||||
|
breachedItems,
|
||||||
|
hibpResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
_updateHealthUI({
|
||||||
|
weak: weak.length,
|
||||||
|
reused: reused.length,
|
||||||
|
breached: breachedItems.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[PassKeeper] Background health check failed:", err);
|
||||||
|
} finally {
|
||||||
|
_hibpRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateHealthUI({ weak, reused, breached }) {
|
||||||
|
const badge = document.getElementById("security-badge");
|
||||||
|
const banner = document.getElementById("health-banner");
|
||||||
|
if (!badge || !banner) return;
|
||||||
|
|
||||||
|
const knownBreached = breached !== null;
|
||||||
|
const issueCount =
|
||||||
|
(weak || 0) + (reused || 0) + (knownBreached ? (breached || 0) : 0);
|
||||||
|
const hasBreaches = knownBreached && breached > 0;
|
||||||
|
const hasSyncIssues = (weak || 0) + (reused || 0) > 0;
|
||||||
|
|
||||||
|
// ── Sidebar badge ──────────────────────────────────────────────────────
|
||||||
|
if (issueCount > 0) {
|
||||||
|
badge.textContent = issueCount > 99 ? "99+" : String(issueCount);
|
||||||
|
badge.classList.remove("hidden", "badge-warn");
|
||||||
|
if (hasBreaches) {
|
||||||
|
badge.classList.remove("badge-warn"); // red (default)
|
||||||
|
} else {
|
||||||
|
badge.classList.add("badge-warn"); // amber
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
badge.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Banner ─────────────────────────────────────────────────────────────
|
||||||
|
// Don't re-render if user already dismissed it this session.
|
||||||
|
if (banner.dataset.dismissed === "1") return;
|
||||||
|
if (issueCount === 0) {
|
||||||
|
banner.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (hasBreaches)
|
||||||
|
parts.push(`<strong>${breached}</strong> breached password${breached !== 1 ? "s" : ""}`);
|
||||||
|
if ((weak || 0) > 0)
|
||||||
|
parts.push(`<strong>${weak}</strong> weak password${weak !== 1 ? "s" : ""}`);
|
||||||
|
if ((reused || 0) > 0)
|
||||||
|
parts.push(`<strong>${reused}</strong> reused password${reused !== 1 ? "s" : ""}`);
|
||||||
|
|
||||||
|
const severity = hasBreaches ? "danger" : "warn";
|
||||||
|
const icon = hasBreaches ? "🚨" : "⚠️";
|
||||||
|
|
||||||
|
banner.className = `health-banner banner-${severity}`;
|
||||||
|
banner.innerHTML = `
|
||||||
|
<span class="health-banner-icon">${icon}</span>
|
||||||
|
<span class="health-banner-text">${parts.join(" · ")}</span>
|
||||||
|
<button class="health-banner-link" id="btn-health-banner-view">View report</button>
|
||||||
|
<button class="health-banner-dismiss" id="btn-health-banner-dismiss" title="Dismiss">×</button>`;
|
||||||
|
banner.classList.remove("hidden");
|
||||||
|
|
||||||
|
document.getElementById("btn-health-banner-view")?.addEventListener("click", () => {
|
||||||
|
switchView("security");
|
||||||
|
});
|
||||||
|
document.getElementById("btn-health-banner-dismiss")?.addEventListener("click", () => {
|
||||||
|
banner.classList.add("hidden");
|
||||||
|
banner.dataset.dismissed = "1";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function renderSecurityDashboard() {
|
async function renderSecurityDashboard() {
|
||||||
const summaryEl = document.getElementById("security-summary");
|
const summaryEl = document.getElementById("security-summary");
|
||||||
const sectionsEl = document.getElementById("security-sections");
|
const sectionsEl = document.getElementById("security-sections");
|
||||||
@@ -828,8 +970,8 @@ const Vault = (() => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
|
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
|
||||||
// Run after the synchronous sections are rendered so the UI is immediately
|
// Use cached results from the background check when available — avoids
|
||||||
// useful. The HIBP API is queried in parallel for all passwords.
|
// re-querying HIBP every time the user opens the Security tab.
|
||||||
const hibpSection = document.createElement("div");
|
const hibpSection = document.createElement("div");
|
||||||
hibpSection.className = "sec-section";
|
hibpSection.className = "sec-section";
|
||||||
hibpSection.innerHTML = `
|
hibpSection.innerHTML = `
|
||||||
@@ -842,13 +984,19 @@ const Vault = (() => {
|
|||||||
</div>`;
|
</div>`;
|
||||||
sectionsEl.appendChild(hibpSection);
|
sectionsEl.appendChild(hibpSection);
|
||||||
|
|
||||||
// Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent.
|
// Use cached results if available, otherwise run fresh checks.
|
||||||
const hibpResults = await Promise.all(
|
let hibpResults;
|
||||||
pwItems.map(async (item) => ({
|
if (_healthCache?.hibpResults) {
|
||||||
item,
|
hibpResults = _healthCache.hibpResults;
|
||||||
count: await checkHibp(item.plain.password),
|
} else {
|
||||||
})),
|
// Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent.
|
||||||
);
|
hibpResults = await Promise.all(
|
||||||
|
pwItems.map(async (item) => ({
|
||||||
|
item,
|
||||||
|
count: await checkHibp(item.plain.password),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
|
const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
|
||||||
|
|
||||||
if (!breached.length) {
|
if (!breached.length) {
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
>
|
>
|
||||||
<span class="sidebar-icon">🛡️</span>
|
<span class="sidebar-icon">🛡️</span>
|
||||||
<span class="sidebar-label">Security</span>
|
<span class="sidebar-label">Security</span>
|
||||||
|
<span id="security-badge" class="sidebar-badge hidden"></span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class="sidebar-item"
|
class="sidebar-item"
|
||||||
@@ -208,6 +209,9 @@
|
|||||||
|
|
||||||
<!-- ── Main ───────────────────────────────────────────────────── -->
|
<!-- ── Main ───────────────────────────────────────────────────── -->
|
||||||
<main class="vault-main">
|
<main class="vault-main">
|
||||||
|
<!-- Health notification banner — shown when background checks find issues -->
|
||||||
|
<div id="health-banner" class="health-banner hidden" role="alert"></div>
|
||||||
|
|
||||||
<!-- Vault view -->
|
<!-- Vault view -->
|
||||||
<div id="view-vault">
|
<div id="view-vault">
|
||||||
<header class="vault-header">
|
<header class="vault-header">
|
||||||
|
|||||||
Reference in New Issue
Block a user