05/18 Enhanced codes and functionalities 7

This commit is contained in:
2026-05-18 19:16:45 -04:00
parent c9808edde2
commit b9e8f5c357
5 changed files with 354 additions and 14 deletions
+92 -2
View File
@@ -52,13 +52,15 @@ passkeeper/
│ │ ├── shared_item.py # ECDH-encrypted cross-user shares; enc_name/iv_name
│ │ ├── emergency_access.py # State machine
│ │ ├── recovery_challenge.py # Server-side recovery challenge (multi-worker safe)
│ │ ├── webauthn_credential.py # Passkey / WebAuthn credentials (one row per key)
│ │ └── audit_log.py
│ ├── routes/
│ │ ├── auth.py # Register, login, MFA, logout, refresh, change-password, recovery
│ │ ├── vault.py # CRUD + GET /export + POST /import
│ │ ├── folders.py
│ │ ├── sharing.py
│ │ ── emergency.py
│ │ ── emergency.py
│ │ └── webauthn.py # Passkey registration, authentication, credential management
│ ├── services/
│ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt,
│ │ # TOTP replay helpers (is_totp_code_used / mark_totp_code_used)
@@ -101,7 +103,8 @@ passkeeper/
│ ├── d4e5f6a7b8c9_add_lockout_and_mfa_backup_codes.py
│ ├── e5f6a7b8c9d0_add_recovery_challenges_table.py
│ ├── 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/
│ ├── reencrypt_totp_secrets.py
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate
@@ -190,6 +193,21 @@ CREATE TABLE recovery_challenges (
);
-- (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 |
| `f6a7b8c9d0e1` | Add totp_used_codes table (TOTP replay prevent.) |
| `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
- **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)
- **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
- **Password generator:** fully CSPRNG (`_cryptoRandInt` rejection-sampling)
- **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
### 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
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` | `shared_item.create/delete/accept` | Sharing (detail: type + id) |
| `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>
POST /api/emergency/<id>/accept|provide|request|deny
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
cryptography>=42.0 # TOTP secret encryption
redis>=5.0 # Rate-limit storage (required in production)
webauthn>=2.0 # Passkey / WebAuthn (py-webauthn)
```
---
+12 -1
View File
@@ -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)
- **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
- **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
- **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
@@ -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
- **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
- **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.
@@ -88,7 +90,7 @@ A database breach exposes only encrypted ciphertext. The server cannot read vaul
| Backend | Python 3.12, Flask 3.x |
| Database | MySQL 8.x |
| 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) |
| Extension | Chrome MV3 / Firefox MV2 |
| 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) |
| `RATELIMIT_STORAGE_URI` | Redis URI — required in production (`redis://127.0.0.1:6379/0`) |
| `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/auth/change-password` | Atomic vault re-encryption |
| 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 |
---
+87
View File
@@ -2463,3 +2463,90 @@ html.sidebar-open {
height: 1px;
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;
}
+158 -10
View File
@@ -234,6 +234,13 @@ const Vault = (() => {
renderFolderList();
renderTagList();
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) {
showToast("Failed to load vault: " + err.message, "error");
} 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() {
const summaryEl = document.getElementById("security-summary");
const sectionsEl = document.getElementById("security-sections");
@@ -828,8 +970,8 @@ const Vault = (() => {
);
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
// Run after the synchronous sections are rendered so the UI is immediately
// useful. The HIBP API is queried in parallel for all passwords.
// Use cached results from the background check when available — avoids
// re-querying HIBP every time the user opens the Security tab.
const hibpSection = document.createElement("div");
hibpSection.className = "sec-section";
hibpSection.innerHTML = `
@@ -842,13 +984,19 @@ const Vault = (() => {
</div>`;
sectionsEl.appendChild(hibpSection);
// Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent.
const hibpResults = await Promise.all(
pwItems.map(async (item) => ({
item,
count: await checkHibp(item.plain.password),
})),
);
// Use cached results if available, otherwise run fresh checks.
let hibpResults;
if (_healthCache?.hibpResults) {
hibpResults = _healthCache.hibpResults;
} 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);
if (!breached.length) {
@@ -4173,4 +4321,4 @@ const Vault = (() => {
}
})();
document.addEventListener("DOMContentLoaded", Vault.init);
document.addEventListener("DOMContentLoaded", Vault.init);
+5 -1
View File
@@ -119,6 +119,7 @@
>
<span class="sidebar-icon">🛡️</span>
<span class="sidebar-label">Security</span>
<span id="security-badge" class="sidebar-badge hidden"></span>
</li>
<li
class="sidebar-item"
@@ -208,6 +209,9 @@
<!-- ── 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 -->
<div id="view-vault">
<header class="vault-header">
@@ -1238,4 +1242,4 @@
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
<script src="{{ url_for('static', filename='js/sharing.js') }}"></script>
<script src="{{ url_for('static', filename='js/vault.js') }}"></script>
{% endblock %}
{% endblock %}