CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
976 lines
55 KiB
Markdown
976 lines
55 KiB
Markdown
# PassKeeper — Password Manager Web App & Browser Extension
|
|
|
|
## Project Overview
|
|
|
|
A full-featured password manager web app and browser extension modelled after LastPass. Users can store, organise, and autofill credentials securely with a zero-knowledge architecture.
|
|
|
|
---
|
|
|
|
## Tech Stack
|
|
|
|
### Development (Windows)
|
|
|
|
- **Backend:** Python 3.12, Flask 3.x
|
|
- **Database:** MySQL 8.x
|
|
- **Frontend:** Vanilla JS (Web Crypto API) + Jinja2 templates
|
|
- **Dev server:** `python run.py`
|
|
|
|
### Production (Ubuntu)
|
|
|
|
- **Web server:** Nginx (reverse proxy, TLS termination)
|
|
- **WSGI server:** Gunicorn
|
|
- **Process manager:** systemd
|
|
- **Database:** MySQL 8.x
|
|
- **TLS:** Let's Encrypt / Certbot
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Browser Extension <──────────────────────────────────────┐
|
|
Web App <──> Nginx ──> Gunicorn ──> Flask App │
|
|
│ │
|
|
MySQL DB │
|
|
│
|
|
REST API (JSON, HTTPS) ───────┘
|
|
```
|
|
|
|
### Project File Structure
|
|
|
|
```
|
|
passkeeper/
|
|
├── app/
|
|
│ ├── __init__.py # App factory, blueprints, CSRF exemptions, security headers
|
|
│ ├── config.py # DevelopmentConfig / ProductionConfig
|
|
│ ├── models/
|
|
│ │ ├── user.py # Argon2id, TOTP encrypted, ECDH keys, recovery
|
|
│ │ ├── vault_item.py # enc_data, iv, enc_name, iv_name
|
|
│ │ ├── folder.py
|
|
│ │ ├── token_blacklist.py # JWT revocation (jti + expires_at)
|
|
│ │ ├── totp_used_code.py # TOTP replay prevention (one-time use per user)
|
|
│ │ ├── 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
|
|
│ │ └── 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)
|
|
│ ├── static/
|
|
│ │ ├── css/app.css # Full responsive stylesheet; collapsible group styles; tag badge styles
|
|
│ │ └── js/
|
|
│ │ ├── crypto.js # deriveAuthHash, deriveVaultKey, encryptItem, decryptItem,
|
|
│ │ │ # encryptName, decryptName, generateSalt
|
|
│ │ ├── auth.js # Login/register + TOTP MFA + VaultSession
|
|
│ │ ├── recover.js
|
|
│ │ ├── sharing.js # ECDH P-256; encryptName/decryptName for shared item names
|
|
│ │ └── vault.js # Full vault UI — see "Key Features" below
|
|
│ └── templates/vault/
|
|
│ └── index.html # Tags field, Auto-Lock setting, Import/Export view + sidebar
|
|
├── extension/
|
|
│ ├── manifest.json # MV3 (Chrome/Edge); web_accessible_resources: []
|
|
│ ├── manifest.firefox.json # MV2 (Firefox); web_accessible_resources: []
|
|
│ ├── background.js # Chrome SW: badges, pending-save "!" badge, CLEAR_SAVE_BADGE
|
|
│ ├── background.firefox.js # Firefox: in-memory session shim, setTimeout idle lock
|
|
│ ├── shared/
|
|
│ │ ├── crypto.js # PBKDF2+AES-GCM; encryptName/decryptName; extractable key
|
|
│ │ ├── psl.js # GENERATED — vendored Public Suffix List + PkPsl.isSameSite
|
|
│ │ └── browser-polyfill.js # chrome=browser alias for Firefox content scripts
|
|
│ ├── popup/
|
|
│ │ ├── popup.html # Tabs: All relevant / All items / Favorites / Recents
|
|
│ │ ├── popup.css # .pk-group-* collapsible styles; .pk-tag badge; .pk-flyout menu
|
|
│ │ └── popup.js # Vault+folder fetch; collapsible groups; Favorites tab;
|
|
│ │ # tag display; clipboard auto-clear; CSPRNG generator;
|
|
│ │ # save badge clear; three-dot flyout; copy-username button
|
|
│ ├── content/
|
|
│ │ └── content.js # _isLikelyUsernameField; _normaliseUrl; debounced input (150ms);
|
|
│ │ # MutationObserver guard; vault_items_cs from chrome.storage.session
|
|
│ ├── bridge/
|
|
│ │ └── bridge.js # SSO bridge (vault domain only)
|
|
│ └── icons/
|
|
├── migrations/versions/
|
|
│ ├── 71d7158dd3b9_add_audit_log_tables_fix_sharing_public_.py
|
|
│ ├── a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py
|
|
│ ├── b2c3d4e5f6a7_add_account_recovery_columns.py
|
|
│ ├── c3d4e5f6a7b8_encrypt_vault_item_name.py
|
|
│ ├── 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
|
|
│ └── h8i9j0k1l2m3_add_webauthn_credentials_table.py # Passkey / WebAuthn credentials
|
|
├── scripts/
|
|
│ ├── update_psl.py # regenerates extension/shared/psl.js
|
|
│ ├── reencrypt_totp_secrets.py
|
|
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate
|
|
│ ├── passkeeper-nginx.conf / passkeeper.service
|
|
├── tests/ # pytest suite — in-memory SQLite, no MySQL needed
|
|
│ ├── conftest.py # app/client fixtures + register/login helpers
|
|
│ ├── test_mfa_gate.py # enc_key_salt withheld until MFA; proof not forgeable
|
|
│ ├── test_key_rotation.py # re-encryption completeness guard
|
|
│ ├── test_session_revocation.py # token_epoch revocation; deleted-account 401
|
|
│ ├── test_webauthn_uv.py # user verification required on both ceremonies
|
|
│ ├── test_sharing_expiry.py # expires_days fails closed
|
|
│ ├── test_registration_privacy.py # register does not disclose account existence
|
|
│ ├── test_emergency_visibility.py # grantor sees requests + retrievals
|
|
│ ├── test_deploy_config.py # nginx/gunicorn/systemd/extension packaging guards
|
|
│ └── js/test_psl.js # PSL same-site matching (node, run in CI)
|
|
├── gunicorn.conf.py # worker class, timeouts, preload_app=False
|
|
├── pytest.ini
|
|
├── requirements-dev.txt
|
|
├── reset_db.py
|
|
├── requirements.txt
|
|
├── wsgi.py / run.py
|
|
└── CLAUDE.md
|
|
```
|
|
|
|
---
|
|
|
|
## Database Schema (MySQL)
|
|
|
|
```sql
|
|
-- Users
|
|
CREATE TABLE users (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
email VARCHAR(255) UNIQUE NOT NULL,
|
|
master_hash VARCHAR(255) NOT NULL, -- Argon2id(authHash)
|
|
enc_key_salt VARCHAR(64) NOT NULL,
|
|
created_at / last_login DATETIME,
|
|
totp_secret VARCHAR(255), -- AES-256-GCM ciphertext
|
|
totp_iv VARCHAR(64),
|
|
totp_enabled TINYINT(1) DEFAULT 0,
|
|
mfa_backup_codes TEXT, -- JSON array of Argon2id-hashed codes
|
|
sharing_public_key VARCHAR(128),
|
|
sharing_private_key_enc TEXT,
|
|
sharing_private_key_iv VARCHAR(64),
|
|
recovery_enc_salt VARCHAR(128),
|
|
recovery_iv VARCHAR(64),
|
|
recovery_verifier VARCHAR(64), -- HMAC key for the recovery challenge
|
|
token_epoch INT NOT NULL DEFAULT 0 -- session generation counter
|
|
);
|
|
|
|
-- Vault Items
|
|
CREATE TABLE vault_items (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT UNSIGNED NOT NULL,
|
|
folder_id INT UNSIGNED,
|
|
item_type VARCHAR(20) NOT NULL DEFAULT 'password',
|
|
name VARCHAR(255) NOT NULL, -- server label only (item type string)
|
|
enc_data TEXT NOT NULL, -- AES-256-GCM ciphertext of payload + tags
|
|
iv VARCHAR(64) NOT NULL,
|
|
enc_name TEXT, -- AES-256-GCM ciphertext of item name (nullable)
|
|
iv_name VARCHAR(64),
|
|
created_at DATETIME DEFAULT NOW(),
|
|
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
|
|
);
|
|
|
|
-- Shared Items
|
|
CREATE TABLE shared_items (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
item_id INT UNSIGNED NOT NULL,
|
|
owner_id INT UNSIGNED NOT NULL,
|
|
recipient_email VARCHAR(255) NOT NULL,
|
|
recipient_id INT UNSIGNED,
|
|
item_name VARCHAR(255) NOT NULL, -- non-sensitive fallback label (= item_type)
|
|
item_type VARCHAR(20) NOT NULL DEFAULT 'password',
|
|
enc_data TEXT NOT NULL, -- ECDH-encrypted item payload
|
|
iv VARCHAR(64) NOT NULL,
|
|
enc_name VARCHAR(512), -- ECDH-encrypted display name (nullable for legacy)
|
|
iv_name VARCHAR(64),
|
|
accepted TINYINT(1) DEFAULT 0,
|
|
created_at DATETIME NOT NULL
|
|
);
|
|
|
|
-- TOTP Used Codes (replay prevention)
|
|
CREATE TABLE totp_used_codes (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT UNSIGNED NOT NULL,
|
|
code VARCHAR(6) NOT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
UNIQUE KEY uq_totp_used_user_code (user_id, code),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
-- Recovery Challenges (multi-worker safe)
|
|
CREATE TABLE recovery_challenges (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT UNSIGNED NOT NULL UNIQUE,
|
|
nonce VARCHAR(64) NOT NULL,
|
|
expected_proof VARCHAR(64) NOT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
-- (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
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Migration History
|
|
|
|
| Revision | Description |
|
|
| ---------------- | ------------------------------------------------ |
|
|
| `71d7158dd3b9` | Add audit_logs; fix sharing_public_key type |
|
|
| `a1b2c3d4e5f6` | Encrypt TOTP secret at rest |
|
|
| `b2c3d4e5f6a7` | Add account recovery columns |
|
|
| `c3d4e5f6a7b8` | Add enc_name + iv_name to vault_items |
|
|
| `d4e5f6a7b8c9` | Add lockout columns + MFA backup codes |
|
|
| `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) |
|
|
| `i9j0k1l2m3n4` | Add expires_at to shared_items |
|
|
| `j0k1l2m3n4o5` | Add recovery_verifier (decouple recovery proof) |
|
|
| `k1l2m3n4o5p6` | Add token_epoch (revoke sessions on pw change) |
|
|
| `l2m3n4o5p6q7` | Add emergency vault retrieval tracking |
|
|
|
|
---
|
|
|
|
## Security Model
|
|
|
|
- **Zero-knowledge:** master password never sent to server
|
|
- `authHash = PBKDF2(masterPassword, email, 100k iter)` → auth only
|
|
- `vaultKey = PBKDF2(masterPassword, enc_key_salt, 600k iter)` → browser memory only
|
|
- All vault data (payload + name + tags) encrypted client-side (AES-256-GCM)
|
|
- **Item name:** `enc_name`/`iv_name` in `vault_items`; server `name` column = item type only
|
|
- **Shared item name:** `enc_name`/`iv_name` encrypted with ECDH shared key; server `item_name` = item type only
|
|
- **Tags:** `plain.tags: string[]` inside `enc_data`; server never sees them
|
|
- **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.
|
|
Every token carries an `epoch` claim checked against `users.token_epoch`;
|
|
changing the master password or recovering the account increments it, which
|
|
revokes every outstanding access AND refresh token. Tokens minted before the
|
|
claim existed decode as `epoch 0` and remain valid until the next change.
|
|
- **MFA gate:** `enc_key_salt` is NOT returned by `/login` when TOTP is enabled —
|
|
it is released by `/mfa/verify` once both factors are proven. Returning it
|
|
early let a password-only attacker forge a recovery proof (see below).
|
|
- **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
|
|
- **Autofill same-site rule:** `PkPsl.isSameSite()` compares *registrable
|
|
domains* using the vendored Public Suffix List — never suffix comparison.
|
|
`host.endsWith("." + h)` treated `evil.github.io` and `victim.github.io` as
|
|
the same site and let an item saved for a bare TLD match everything under it.
|
|
Used identically by `content.js`, `popup.js`, `background.js` and
|
|
`background.firefox.js`; all four fall back to exact hostname equality if
|
|
`psl.js` fails to load (strict, so a failure loses matches rather than
|
|
leaking credentials). The PRIVATE section of the list is required — that is
|
|
where `github.io` / `vercel.app` / `herokuapp.com` live.
|
|
- **Password generator:** fully CSPRNG (`_cryptoRandInt` rejection-sampling)
|
|
- **Decrypted vault data:** `chrome.storage.session` only — never to disk
|
|
- **Clipboard auto-clear:** 30 s after any password/username copy (web + extension)
|
|
- **Breach detection:** HIBP k-anonymity — only 5-char SHA-1 prefix transmitted
|
|
- **Recovery proof key:** `HMAC-SHA256(key=recovery_verifier, msg=nonce)`.
|
|
`recovery_verifier` = `PBKDF2(recoveryCode, "passkeeper-recovery-verifier:" + email, 200k)`,
|
|
derived client-side from the recovery code alone and used for nothing else.
|
|
It must NEVER be `enc_key_salt`: that value doubles as the vault-key PBKDF2
|
|
salt and is disclosed to the client at login, so keying the proof with it
|
|
allowed anyone holding the master password to pull the entire vault from the
|
|
unauthenticated `/recovery/items` — bypassing MFA. Accounts whose recovery
|
|
code predates the column fall back to the legacy key and are flagged via
|
|
`recovery_is_legacy` on `/recovery/status`.
|
|
- **Key rotation completeness:** `change_password` and `/recover` refuse (409
|
|
`incomplete_reencryption`) unless the client's `items` payload covers every
|
|
vault item the user owns — a short payload would rotate `enc_key_salt` and
|
|
leave the missing items permanently undecryptable. `/recover` accepts
|
|
`allow_partial: true` after the user confirms the loss (otherwise one corrupt
|
|
item locks them out forever); `change_password` has no such override.
|
|
- **Passkeys:** both ceremonies use `UserVerificationRequirement.REQUIRED` and
|
|
`require_user_verification=True`. A passkey replaces password AND TOTP, so
|
|
possession of an unlocked device must not be sufficient.
|
|
- **Account recovery:** challenge-response via HMAC-SHA256; `enc_key_salt` NOT returned by `/recovery/data` — client must derive it by decrypting the recovery blob (proves possession of recovery code without transmitting it); challenge rotated on each `/recovery/items` call to prevent proof replay; recovery key derived with the user's email as a per-user PBKDF2 salt — legacy fixed salt `'passkeeper-recovery'` accepted transparently for codes created before this change
|
|
- **folder_id ownership:** validated server-side on all create/update/import operations — user cannot assign items to another user's folder
|
|
- **Registration privacy:** `POST /api/auth/register` returns an identical 202
|
|
whether or not the address exists, and performs an equivalent Argon2id hash on
|
|
both branches so timing does not reinstate the oracle. Duplicate attempts are
|
|
audited under `auth.register_duplicate`. Fully closing this needs email
|
|
verification so the address owner is told — until then the oracle is removed
|
|
but the owner cannot be notified.
|
|
- **Emergency access visibility:** `accept`, `request` and `vault_retrieved` are
|
|
audited under BOTH parties' user_ids. `/api/auth/audit-log` filters by
|
|
`user_id`, so an entry written only under the acting user is invisible to the
|
|
other — which meant a grantee could request and retrieve a vault snapshot
|
|
without anything reaching the grantor. `vault_retrieved_at` /
|
|
`vault_retrieval_count` on `emergency_access` record every fetch. Retrieval is
|
|
intentionally NOT blocked after the first time (the grantor may be unable to
|
|
re-provision); the wait period is the gate, and the grantor can revoke.
|
|
- **Audit logs:** never contain plaintext item names, shared item names, or vault data
|
|
|
|
---
|
|
|
|
## Extension Storage Architecture
|
|
|
|
| Data | Storage | Reason |
|
|
| -------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------- |
|
|
| `access_token`, `vault_key_jwk`, `vault_items`, `vault_items_cs` | `chrome.storage.session` | Memory-only, cleared on browser close |
|
|
| `refresh_token`, `enc_key_salt`, `pending_save`, `save_blocklist`, `idle_lock_seconds` | `chrome.storage.local` | Persists across restarts |
|
|
| Web-app session timeout | `localStorage` (`web_idle_minutes`) | Per-browser preference |
|
|
|
|
### Critical: `vault_items_cs` is in `session`, NOT `local`
|
|
|
|
Requires Chrome 111+. `content.js` `onChanged` listener watches `area === 'session'`. Do not revert — reverting persists decrypted passwords to disk.
|
|
|
|
### Pending-save badge
|
|
|
|
`background.js` sets a red `"!"` badge on `SAVE_CREDENTIALS`. Cleared via `CLEAR_SAVE_BADGE` when user acts on the save prompt in the popup.
|
|
|
|
---
|
|
|
|
## Key Implementation Details
|
|
|
|
### `password_changed_at` — accurate password age tracking
|
|
|
|
Stored as `plain.password_changed_at: ISO-8601 string` inside the encrypted `enc_data` blob. Server never sees it.
|
|
|
|
**Written by:**
|
|
- `vault.js` `handleFormSubmit` — **create**: always set to `now`. **Edit**: only updated when the password field value actually changed vs. the decrypted existing item in `_items`. Unchanged password → existing `password_changed_at` preserved. No existing timestamp + unchanged → absent (dashboard falls back to `created_at`).
|
|
- `popup.js` `saveCredential` and `addItemToVault` — always set to `now` for new extension-saved credentials.
|
|
- `toggleFavorite` — spreads `{ ...item.plain, tags: newTags }`, automatically preserving the timestamp.
|
|
|
|
**Read by:**
|
|
- `renderSecurityDashboard` — `ageRef = plain?.password_changed_at || created_at`; items with a recent `password_changed_at` are no longer falsely flagged as old even if the item itself is old.
|
|
- Backwards compatible: items without `password_changed_at` fall back to `created_at`.
|
|
|
|
### Auto-lock on tab visibility change
|
|
|
|
`_startWebIdleTracking()` now registers a `visibilitychange` listener in addition to the mouse/keyboard events.
|
|
|
|
**Hidden:** clears the inactivity timer (user cannot be active on a hidden tab), records `_hiddenAt = Date.now()`.
|
|
|
|
**Visible again:** compares elapsed hidden time against the idle timeout.
|
|
- Hidden `≥ timeout` → immediate lock (same `VaultSession.clear()` + `showUnlockOverlay()` + toast as inactivity lock).
|
|
- Hidden `< timeout` → clears `_hiddenAt`, resumes timer from zero.
|
|
|
|
This catches screen-lock, minimize, and long tab switches. Short tab switches (< timeout) do not trigger a lock.
|
|
|
|
### Emergency access stale snapshot detection
|
|
|
|
`EmergencyAccess._enc_vault_is_legacy()` — parses `enc_vault` JSON server-side (no decryption) and returns `True` if any item has a `name` key but lacks `enc_name`. Exposed as `enc_vault_is_legacy` in `to_dict()`.
|
|
|
|
`renderEmergencyGrants()` — `ready` grants now show three states:
|
|
- `enc_vault_is_legacy: true` → amber `⚠ Outdated snapshot` badge + **Re-provision** button
|
|
- `enc_vault_is_legacy: false` → secondary **Update Recovery Data** button
|
|
- `accepted` (no snapshot yet) → primary **Provide Recovery Data** button
|
|
|
|
After re-provisioning, `enc_vault_is_legacy` returns `false` and the warning disappears.
|
|
|
|
### Extension health badge
|
|
|
|
`_runPopupHealthCheck()` in `popup.js` — fires after every `fetchAndDecryptVault()`. Computes weak/reused synchronously, sends a preliminary `HEALTH_UPDATE` to the background SW, then runs HIBP in parallel (`_popupCheckHibp`). Sends a final `HEALTH_UPDATE` with breach count.
|
|
|
|
`background.js` / `background.firefox.js`:
|
|
- `HEALTH_UPDATE` handler: stores `{ breached, weak, reused }` in `chrome.storage.local` as `health_status`, calls `applyHealthBadge()`.
|
|
- `applyHealthBadge()`: on tabs with no match-count badge, shows red `⚠` (breach) or amber `⚠` (weak/reused). Never overwrites the blue match-count badge or the pending-save `!` badge.
|
|
- `CLEAR_SAVE_BADGE`: after clearing `!`, immediately re-applies health badge.
|
|
- Idle lock: removes `health_status` from local storage.
|
|
|
|
**Badge priority:** pending-save `!` (red) > match count (blue) > health `⚠` (red/amber).
|
|
|
|
### Vault health notifications (background checks)
|
|
|
|
`runBackgroundHealthCheck()` fires after every `loadVault()` call — async, non-blocking.
|
|
|
|
**Flow:**
|
|
1. Computes weak + reused counts synchronously from `_items` → updates sidebar badge instantly
|
|
2. Runs HIBP k-anonymity checks in parallel (`checkHibp`) → updates badge + banner when done
|
|
|
|
**Module state:**
|
|
- `_healthCache` — `{ weak, reused, breached, breachedItems, hibpResults }` — set after first run
|
|
- `_hibpRunning` — boolean guard prevents concurrent runs
|
|
|
|
**`_updateHealthUI({ weak, reused, breached })`** — renders:
|
|
- **Sidebar badge** (`#security-badge`) on the 🛡️ Security link: red for breaches, amber for weak/reused only, hidden when clean
|
|
- **Dismissible banner** (`#health-banner`) above the vault list: summarises issues with "View report" link → Security tab; dismiss hides for the session (`banner.dataset.dismissed = "1"`); resets on next vault load
|
|
|
|
**Security tab caching:** `renderSecurityDashboard` checks `_healthCache?.hibpResults` before querying HIBP — avoids double-calling the API within the same session.
|
|
|
|
**Banner reset:** `banner.dataset.dismissed` is set to `"0"` on each vault reload so fresh results (e.g. after a password change) are visible again.
|
|
|
|
### 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:** `register_begin` accepts optional `attachment` in the POST body: `"platform"` (default — device biometrics) or `"cross-platform"` (roaming — YubiKey, phone QR, NFC). The settings UI exposes a `<select>` with both options. Credential type is shown in the passkeys list using transport hints (`internal` → 📱 Device, `usb/nfc/ble` → 🔑 Security key).
|
|
|
|
**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`.
|
|
|
|
### Argon2 transparent rehash
|
|
|
|
`verify_auth_token(auth_hash, stored_hash, user=user)` calls `ph.check_needs_rehash()` on success. If the stored hash uses outdated parameters (e.g. after raising `ARGON2_TIME_COST`), the hash is silently upgraded in the same DB commit as the login success. Pass `user=user` at all verification callsites — both `login` and `change_password`.
|
|
|
|
### folder_id ownership validation
|
|
|
|
`_validate_folder_id(folder_id, user_id)` in `vault.py` queries `Folder` by both `id` and `user_id`. Returns sanitised int or `None`. Raises `ValueError` on mismatch. Applied in `create_item` (400 on invalid), `update_item` (400 on invalid), `import_items` (silently clears to `None` — item still imports to root).
|
|
|
|
### Account recovery flow
|
|
|
|
```
|
|
1. GET /recovery/data → server creates challenge; returns nonce + recovery blob
|
|
enc_key_salt NOT returned (client must decrypt blob)
|
|
2. Client decrypts blob with recovery code → gets enc_key_salt
|
|
Recovery key = PBKDF2(recoveryCode, userEmail, 200k iter)
|
|
Legacy fallback: PBKDF2(recoveryCode, "passkeeper-recovery", 200k iter)
|
|
3. Client computes proof = HMAC-SHA256(enc_key_salt, nonce)
|
|
4. GET /recovery/items → validates proof; consumes challenge; re-issues fresh challenge
|
|
with same expected_proof + new nonce (prevents proof replay)
|
|
Returns { id, enc_data, iv, enc_name, iv_name } per item
|
|
5. POST /recover → validates proof again; consumes rotated challenge; atomically
|
|
re-encrypts vault (enc_data + enc_name) + resets password
|
|
```
|
|
|
|
### Password change key rotation
|
|
|
|
`handleChangePassword` in `vault.js` performs a fully atomic key rotation in a single `POST /api/auth/change-password` call:
|
|
|
|
1. Derives `newVaultKey = PBKDF2(newPassword, newEncKeySalt, 600k iter)`
|
|
2. Re-encrypts every vault item's `enc_data` + `enc_name` with `newVaultKey`
|
|
3. Re-encrypts the ECDH sharing private key at the raw bytes level:
|
|
- Fetches `GET /api/sharing/keys` → gets `private_key_enc` / `private_key_iv`
|
|
- `AES-GCM.decrypt(vaultKey, ...)` → raw private key bytes
|
|
- `AES-GCM.encrypt(newVaultKey, newIv, ...)` → new ciphertext
|
|
- Sends `sharing_private_key_enc` / `sharing_private_key_iv` in the payload
|
|
4. Server atomically updates `master_hash`, `enc_key_salt`, all vault items,
|
|
and `sharing_private_key_enc`/`sharing_private_key_iv` in one transaction
|
|
|
|
The sharing key uses raw `SubtleCrypto` calls (not `SharingCrypto.decryptPrivateKey`) because `decryptPrivateKey` imports the key with `extractable: false`, making it impossible to re-export the bytes for re-encryption.
|
|
|
|
### Shared item name encryption
|
|
|
|
When creating a share, the client encrypts `item.name` with `SharingCrypto.encryptName(sharedKey, name)` → `enc_name`/`iv_name`. The server receives `item_name = item.item_type` (non-sensitive type label) for the `NOT NULL` column. On the recipient's inbox, `enc_name`/`iv_name` are decrypted client-side with `SharingCrypto.decryptName()` when the user clicks View. Legacy shares (pre-migration, no `enc_name`) fall back to showing `item_name` (the type string).
|
|
|
|
### Live sync of owner edits to accepted shares
|
|
|
|
When the owner edits a vault item, `buildSharedUpdates(itemId, plainData, name)` re-encrypts the item for every accepted share using each recipient's ECDH public key, then POSTs all re-encrypted blobs in `shared_updates[]` inside `PUT /api/vault/<id>`. The server updates `enc_data`, `iv`, `enc_name`, `iv_name` on each matching `SharedItem` row and logs a `shared_item.update` audit entry.
|
|
|
|
On the grantee side there are two paths:
|
|
- **Vault list (main view):** `openFreshSharedDetail()` calls `loadSharedItemsForVault()` → re-fetches `/api/sharing/inbox` on every View click — always current.
|
|
- **Sharing tab "View" button:** previously baked `enc_data`/`iv`/`enc_name`/`iv_name` into HTML `data-*` attributes at render time. Fixed to fetch `/api/sharing/inbox` fresh on every click, look up the share by ID, then decrypt the server's latest ciphertext. This prevents stale data if the sharing tab was already open when the owner edited.
|
|
|
|
**Gotcha:** never read `data-enc` / `data-iv` / `data-owner-key` from the View button for decryption — those attrs may be stale. Always re-fetch from `/api/sharing/inbox`.
|
|
|
|
### Vault item tags
|
|
|
|
- Stored as `plain.tags: string[]` inside `enc_data`. No schema change ever needed.
|
|
- **Web app:** `_parseTags(str)` → comma-split, lowercase, dedup, sort. `_allTags()` collects across all items. `renderTagList()` builds sidebar. Tags field in modal has live badge preview.
|
|
- **Extension:** `.pk-tag` badges below item subtitle. Favorites tab = items where `tags.includes('favorite')`. Favorited items show gold ★ in site label.
|
|
- `handleSearch()` pool correctly branches on `itemType` / `folder` / `tag` filter types.
|
|
|
|
### Collapsible folder groups
|
|
|
|
- **Web app:** `_collapsedGroups` Set persists state across re-renders. Header shows name + count badge + chevron (▼/▶). Click toggles and re-renders.
|
|
- **Extension:** only on "All items" tab. `_folders` fetched in parallel with vault. `_collapsedFolders` Set. `folderName(id)` resolves to display name with `(none)` fallback.
|
|
|
|
### Import / Export
|
|
|
|
- **Encrypted JSON export:** `GET /api/vault` → versioned envelope → download. Zero-knowledge.
|
|
- **CSV export:** decrypt client-side → `name, url, username, password, notes`.
|
|
- **JSON import:** sends encrypted blobs to `POST /api/vault/import` directly.
|
|
- **CSV import:** RFC 4180-compliant parser (handles `""` embedded quotes); parses Chrome / Bitwarden / 1Password formats; encrypts client-side before POSTing.
|
|
- Both endpoints write audit logs: `vault_item.export`, `vault_item.import`.
|
|
- Import view resets state (file input, preview, result) every time the view is entered.
|
|
|
|
### `apiFetch` error handling
|
|
|
|
`apiFetch` throws on **all** non-ok responses including 404. Thrown error carries `e.status` for callers that need to branch. Callers should use `try/catch` rather than checking `res.ok` after the call.
|
|
|
|
### Web-app session timeout
|
|
|
|
- `_startWebIdleTracking()` called at end of `init()`. Listens to `mousemove`, `mousedown`, `keydown`, `touchstart`, `scroll`.
|
|
- On timeout: `VaultSession.clear()` → unlock overlay → toast.
|
|
- Stored in `localStorage` as `web_idle_minutes`. Default 15 min. Options: Never/5/10/15/30/60.
|
|
- Exposed in Account Settings → Auto-Lock (`<select id="web-idle-select">`).
|
|
|
|
### Browser history / back-button
|
|
|
|
- `switchView(view, { pushState = true })` calls `history.pushState({ view }, '', '#view-name')`.
|
|
- `popstate` listener in `init()` restores view. Direct hash links (`/vault#security`) work.
|
|
- `Vault.switchToImportExport()` exposed on public return object for sidebar `<li onclick>` fallback.
|
|
|
|
### Clipboard auto-clear
|
|
|
|
- Web app: `copyToClipboard()` uses `_clipboardClearTimer` setTimeout 30s → `writeText('')`.
|
|
- Extension: `_copyWithAutoClear()` same pattern. Applied to password, username, and flyout copies.
|
|
|
|
### Missing 2FA warning
|
|
|
|
- Security dashboard "No 2FA Saved" section: password items with URL but no `totp_uri`.
|
|
- Uses existing `extractTotpSecret()` and `makeSection()`.
|
|
|
|
### Security score age penalty fix
|
|
|
|
- `old` only includes items that are **also weak or reused** (`weakOrReusedIds.has(i.id)`).
|
|
- Strong unique unchanged passwords no longer penalised.
|
|
|
|
### Keyboard shortcut
|
|
|
|
- `manifest.json`: `commands._execute_action`, `Ctrl+Shift+L` / `Cmd+Shift+L`.
|
|
- `manifest.firefox.json`: `_execute_browser_action`.
|
|
- Customisable at `chrome://extensions/shortcuts`.
|
|
|
|
### Firefox compatibility
|
|
|
|
| File | Purpose |
|
|
| ---------------------------- | ------------------------------------------------------------------- |
|
|
| `manifest.firefox.json` | MV2: `browser_action`, `background.scripts` |
|
|
| `background.firefox.js` | In-memory session shim, `setTimeout` idle lock, `browserAction` API |
|
|
| `shared/browser-polyfill.js` | `chrome = browser` alias for content scripts |
|
|
|
|
Load in Firefox: `about:debugging` → This Firefox → Load Temporary Add-on → `manifest.firefox.json`.
|
|
|
|
### `web_accessible_resources`
|
|
|
|
Both manifests declare `web_accessible_resources: []` (MV3) / `[]` (MV2). This prevents any external page from loading extension resources via `chrome-extension://` or `moz-extension://` URLs, blocking extension fingerprinting.
|
|
|
|
### `_normaliseUrl()` — bare-domain matching
|
|
|
|
```js
|
|
function _normaliseUrl(raw) {
|
|
if (!raw) return null;
|
|
const s = raw.trim();
|
|
if (/^https?:\/\//i.test(s)) return s;
|
|
if (s.startsWith("//")) return "https:" + s;
|
|
return "https://" + s;
|
|
}
|
|
```
|
|
|
|
In both `content.js` and `popup.js`. Prevents silent match failures for bare domains.
|
|
|
|
### `_isLikelyUsernameField()` — credential field heuristic
|
|
|
|
1. **YES:** `autocomplete="username|email|tel"`
|
|
2. **NO:** non-credential autocomplete (`name`, `organization`, `search`, etc.)
|
|
3. **YES:** `name/id/placeholder/aria-label` matches `user|email|mail|login|phone|tel|mobile|account`
|
|
4. **Otherwise:** not decorated
|
|
|
|
### MutationObserver guard
|
|
|
|
Inspects added/removed nodes — if all carry `__pk` prefix, returns early. Prevents re-decoration loops when the extension injects/removes its own UI.
|
|
|
|
### `showDropdown` debounced 150ms on `input`
|
|
|
|
`_debounce(fn, ms)` helper. `focus` listener remains instant.
|
|
|
|
### Three-dot flyout menu
|
|
|
|
`.pk-flyout` div anchored below button. Dynamic items: Open URL / Copy username / Copy password. Closes on outside click.
|
|
|
|
### AuditLog pattern
|
|
|
|
```python
|
|
db.session.add(item)
|
|
db.session.flush() # populates item.id
|
|
AuditLog.log(user_id=..., action='vault_item.create', resource_id=item.id,
|
|
detail=f'Created {item_type} item (id={item.id})') # NO plaintext name
|
|
db.session.commit() # atomic
|
|
```
|
|
|
|
Audit log details **never** contain plaintext item names, shared item names, or any decrypted vault data. Use `item_type` and `id` only.
|
|
|
|
### Common gotchas
|
|
|
|
- `item_type`: use `db.String(20)`, not `db.Enum(ItemType)` — enum lazy-load breaks `.value`
|
|
- `INTEGER(unsigned=True)`: requires `from sqlalchemy.dialects.mysql import INTEGER`
|
|
- All datetimes: naive UTC `datetime.now(timezone.utc).replace(tzinfo=None)` — never mix with timezone-aware
|
|
- Extension vault key: `extractable: true` (web app uses `false`)
|
|
- PyJWT `sub`: `str(user_id)` on encode, `int(payload['sub'])` on decode
|
|
- TOTP key generation: `python -c "import secrets; print(secrets.token_hex(32))"`
|
|
- `#vault-list` ID must not be renamed — `vault.js` renders into it directly
|
|
- `_validate_folder_id` must be called for any user-supplied `folder_id` before DB write
|
|
- `verify_auth_token` must receive `user=user` at both `login` and `change_password` to enable Argon2 rehash
|
|
- APScheduler cleanup job handles `TokenBlacklist`, `RecoveryChallenge`, AND `TotpUsedCode`; guard with `os.environ.get('WERKZEUG_RUN_MAIN') == 'true'` in Flask debug mode to prevent double-start
|
|
- `password_changed_at` lives inside `plain` (encrypted) — never in the server schema
|
|
- WebAuthn `attachment`: `"cross-platform"` for security keys; `"platform"` for device biometrics (default)
|
|
- `enc_vault_is_legacy` check in `EmergencyAccess.to_dict()` is pure JSON inspection — no decryption
|
|
- Audit entries are only visible to the user_id they are written under — mirror cross-party events (use `_log_for_both` in `emergency.py`) or the other party never sees them
|
|
- `/register` must return the SAME body and status for new and existing addresses, and hash on both paths — returning early on duplicate reinstates a timing oracle
|
|
- Never return `str(e)` from exception handlers — log with `_log.exception(...)` and return a generic user-facing message to avoid leaking DB schema details or query fragments
|
|
- `extension/shared/psl.js` is GENERATED — never hand-edit; run `python scripts/update_psl.py`. It must load BEFORE content.js / popup.js / background.js in every manifest
|
|
- Never reintroduce `endsWith("." + host)` host matching anywhere in the extension — `tests/test_deploy_config.py` fails the build if it reappears
|
|
- nginx rate zones: mind `r/s` vs `r/m`. `api_limit` was `60r/m` (1 req/s for the whole API) and caused spurious 429s on normal vault use
|
|
- `preload_app` must stay `False` in `gunicorn.conf.py` — APScheduler's thread does not survive `fork()`, so `--preload` silently disables the cleanup job
|
|
- nginx `proxy_read_timeout` must stay BELOW gunicorn `timeout`, else a slow request returns 502 instead of 504
|
|
- `WatchdogSec` in the systemd unit requires `Type=notify` + `NotifyAccess=main`; without them systemd SIGKILLs the service on a loop
|
|
- `ExecReload` must not use `USR2` — it forks a second master and strands `$MAINPID`
|
|
- `ProductionConfig.validate()` is called from `create_app()`, NOT at class-definition time — running it in the class body made `import app.config` fail without production secrets, breaking tests and local tooling
|
|
- Always pass `user.token_epoch` to `generate_tokens()` — the `0` default exists only so stale call sites fail visibly
|
|
- `SharingCrypto.decryptPrivateKey` imports the key with `extractable: false` — use raw `SubtleCrypto` calls when you need the key bytes (e.g. re-encryption on password change)
|
|
|
|
---
|
|
|
|
## Audit Action Catalog
|
|
|
|
| Module | Action | Trigger |
|
|
| -------------- | ------------------------------------------------------ | ------------------------------ |
|
|
| `auth.py` | `auth.register` | New account |
|
|
| `auth.py` | `auth.register_duplicate` | Register attempt on an existing address (no email in detail) |
|
|
| `auth.py` | `auth.login` / `auth.login_failed` | Login success/fail |
|
|
| `auth.py` | `auth.account_locked` | Failed login lockout |
|
|
| `auth.py` | `auth.mfa_enable/disable/verify` | TOTP actions |
|
|
| `auth.py` | `auth.mfa_backup_code_used` | Backup code login |
|
|
| `auth.py` | `auth.mfa_backup_codes_regenerated` | Backup code regen |
|
|
| `auth.py` | `auth.change_password` / `auth.change_password_failed` | Password change |
|
|
| `auth.py` | `auth.delete_account` / `auth.delete_account_failed` | Deletion |
|
|
| `auth.py` | `auth.recovery_setup/failed/items_denied/success` | Recovery |
|
|
| `vault.py` | `vault_item.create/update/delete` | CRUD (detail: type + id only) |
|
|
| `vault.py` | `shared_item.update` | Re-encrypted N accepted share copies when owner edits item |
|
|
| `vault.py` | `vault_item.export` / `vault_item.import` | Import/Export |
|
|
| `folders.py` | `folder.create/update/delete` | Folder CRUD |
|
|
| `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 |
|
|
| `emergency.py` | `emergency_access.accept/request/vault_retrieved` | Logged under BOTH grantor and grantee user_ids |
|
|
| `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 |
|
|
|
|
---
|
|
|
|
## REST API
|
|
|
|
```
|
|
POST /api/auth/register|login|logout|refresh|recover
|
|
GET /api/auth/me|mfa/status|mfa/setup|recovery/status|recovery/data|recovery/items
|
|
POST /api/auth/mfa/enable|disable|verify|backup-codes/regenerate|change-password|recovery/setup
|
|
DELETE /api/auth/account
|
|
|
|
GET /api/vault # list all encrypted items
|
|
POST /api/vault # { name, item_type, folder_id?, enc_data, iv, enc_name?, iv_name? }
|
|
GET /api/vault/<id>
|
|
PUT /api/vault/<id>
|
|
DELETE /api/vault/<id>
|
|
GET /api/vault/export # returns encrypted item array
|
|
POST /api/vault/import # accepts array; returns { imported, skipped }
|
|
|
|
GET|POST /api/folders
|
|
PUT|DELETE /api/folders/<id>
|
|
|
|
GET|POST /api/sharing/keys
|
|
GET /api/sharing/public-key
|
|
GET|POST /api/sharing # POST: { item_id, recipient_email, enc_data, iv,
|
|
DELETE /api/sharing/<id> # item_name (=item_type), item_type,
|
|
GET /api/sharing/inbox # enc_name, iv_name }
|
|
POST /api/sharing/inbox/<id>/accept
|
|
|
|
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
|
|
```
|
|
|
|
---
|
|
|
|
## Development Setup
|
|
|
|
```bash
|
|
python -m venv .venv && .venv\Scripts\activate
|
|
pip install -r requirements.txt
|
|
# .env: MYSQL_*, SECRET_KEY, JWT_SECRET_KEY, TOTP_ENCRYPTION_KEY
|
|
mysql -u root -p -e "CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
|
python reset_db.py
|
|
python run.py
|
|
```
|
|
|
|
## Production Deployment
|
|
|
|
```bash
|
|
flask db upgrade
|
|
sudo systemctl reload passkeeper
|
|
```
|
|
|
|
## Python Dependencies
|
|
|
|
```
|
|
flask>=3.0 flask-sqlalchemy>=3.1 flask-migrate>=4.0 flask-login>=0.6
|
|
flask-wtf>=1.2 flask-limiter>=3.5 flask-cors>=4.0
|
|
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)
|
|
```
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- Never log decrypted vault data server-side — audit details use `item_type` + `id` only
|
|
- Background health check runs after every vault load — badge + banner update without user action
|
|
- HIBP results cached in `_healthCache` per session — Security tab reuses them, no double-query
|
|
- Extension health badge stored in `chrome.storage.local` (`health_status`) — survives SW restarts
|
|
- `password_changed_at` in `plain` enables accurate old-password detection without schema changes
|
|
- Tab visibility lock fires immediately when hidden duration ≥ idle timeout — catches screen lock
|
|
- Tags live in `enc_data` as `plain.tags: string[]` — no schema change ever needed
|
|
- `vault_items_cs` is in `chrome.storage.session` — decrypted data never written to disk
|
|
- HIBP checks run progressively — synchronous sections render first, then parallel async checks
|
|
- Clipboard cleared 30 s after every password/username copy (web app + extension)
|
|
- Web-app idle timeout in `localStorage` (`web_idle_minutes`); default 15 min
|
|
- Browser back/forward works for all five vault views via `history.pushState`
|
|
- Pending save badge (`"!"`) set on `SAVE_CREDENTIALS`, cleared via `CLEAR_SAVE_BADGE`
|
|
- Firefox: use `manifest.firefox.json` + `background.firefox.js` + `browser-polyfill.js`
|
|
- Redis required in production for rate limiting
|
|
- Recovery code never stored server-side; password change clears it (user must regenerate)
|
|
- `save_blocklist` in `chrome.storage.local` suppresses save banner per hostname
|
|
|
|
---
|
|
|
|
## CSP & Nginx Header Architecture
|
|
|
|
### Why Nginx — not Flask — owns the authoritative CSP
|
|
|
|
The deployment stack is `Browser → Nginx → Gunicorn → Flask`. When Nginx emits a
|
|
`Content-Security-Policy` header with `always`, it **replaces** any CSP header
|
|
Flask emits upstream. Changes to `app/__init__.py` or `base.html` alone are
|
|
ineffective in production — **`scripts/passkeeper-nginx.conf` is the single source
|
|
of truth for CSP in production.**
|
|
|
|
### Nginx `add_header` inheritance rule (critical)
|
|
|
|
> Any `location` block that declares even one `add_header` directive silently drops
|
|
> **all** `add_header` directives from every parent block for that location.
|
|
|
|
The `/static/` block uses `add_header Cache-Control`. Without explicitly repeating
|
|
all security headers inside that block, every static asset (`vault.js`, `app.css`,
|
|
etc.) is served with **no** CSP, no HSTS, no `X-Frame-Options` — none.
|
|
|
|
**All security headers must be present in both the `server` block and the
|
|
`/static/` location block.** Keep them in sync whenever either is modified.
|
|
|
|
### Approved external `connect-src` origins
|
|
|
|
| Origin | Purpose |
|
|
| -------------------------------- | -------------------------------------------------- |
|
|
| `https://api.pwnedpasswords.com` | HIBP k-anonymity breach check (Security Dashboard) |
|
|
|
|
`connect-src 'self' https://api.pwnedpasswords.com` must appear in the CSP in
|
|
**both** the `server` block and the `/static/` location block in
|
|
`passkeeper-nginx.conf`.
|
|
|
|
### Flask-side CSP (`app/__init__.py` + `base.html`)
|
|
|
|
Flask also sets a CSP header and `base.html` has a `<meta http-equiv>` CSP tag.
|
|
Keep these in sync with the Nginx config for correctness in development (where
|
|
Nginx is not present). Note:
|
|
|
|
- `frame-ancestors` is valid **only** in HTTP headers — never in `<meta>` CSP tags.
|
|
The browser silently ignores it in meta tags and logs a warning.
|
|
- The `<meta>` tag CSP does not enforce `frame-ancestors`; the Nginx HTTP header does.
|
|
|
|
### Inline event handler prohibition
|
|
|
|
`script-src 'self'` blocks all inline `on*` HTML attributes. Do not add `onclick`,
|
|
`onchange`, or any other inline handler to any template. Wire all interactions via
|
|
`addEventListener` in the corresponding JS file instead.
|
|
|
|
The `Vault.switchToImportExport()` export on the `vault.js` public API exists for
|
|
historical reasons. The `#sidebar-import-export` element is wired via
|
|
`addEventListener` in `init()` — the exported function is not needed for new code.
|
|
|
|
### Third-party browser extension noise
|
|
|
|
Console errors referencing `isCheckout`, `content-script.js`, or the extension ID
|
|
`clmkdohmabikagpnhjmgacbclihgmdje` originate from a third-party shopping/coupon
|
|
browser extension — not from PassKeeper. These can be ignored.
|
|
|
|
The `[PassKeeper] decorateFields: host=…, matched=0 of 0 items` log from
|
|
`content.js` is a routine debug message (not an error): the content script found
|
|
no stored credentials matching the current hostname, which is expected on the vault
|
|
page itself.
|
|
|
|
---
|
|
|
|
## Roadmap / To-Do
|
|
|
|
Features planned for future implementation. Ordered by priority within each category.
|
|
|
|
### Recently completed (Aug 2026)
|
|
|
|
- MFA bypass closed: `enc_key_salt` withheld until the second factor; recovery
|
|
proof rebased onto `recovery_verifier`
|
|
- Key-rotation completeness guard on `change_password` / `/recover`
|
|
- Session revocation via `token_epoch`; `require_jwt` now verifies the user exists
|
|
- Passkey ceremonies require user verification
|
|
- Autofill matching moved onto the Public Suffix List (registrable domains)
|
|
- Share `expires_days` fails closed instead of silently meaning "never"
|
|
- nginx `api_limit` corrected from 60r/m to 10r/s
|
|
- Registration no longer discloses account existence (status, body and timing)
|
|
- Emergency access: requests and vault retrievals are now visible to the grantor
|
|
- pytest suite (66 tests) + PSL node test + CI jobs; `gunicorn.conf.py`;
|
|
systemd watchdog removed
|
|
|
|
### High priority — user-facing
|
|
|
|
**1. One-time share links**
|
|
Generate a time-limited, single-use encrypted link to share a credential with someone who has no PassKeeper account. The decryption key lives in the URL fragment (never sent to server). Auto-expires and self-destructs after first view.
|
|
- New DB table: `share_links` (token, enc_data, iv, enc_key_fragment_hash, expires_at, used_at)
|
|
- Public route: `GET /s/<token>` — serves a minimal HTML page
|
|
- Client derives key from URL fragment, decrypts, displays once
|
|
- No auth required for recipient
|
|
|
|
**2. Custom fields**
|
|
Let users add arbitrary key-value pairs to any item type. Fields stored encrypted inside `plain.custom_fields: [{label, value}]` — no schema change needed.
|
|
- Add "Add field" button in item modal
|
|
- Renders as label + value rows; value masked by default for sensitive fields
|
|
- Searchable via expanded `handleSearch`
|
|
|
|
**3. Trusted devices**
|
|
After successful login + MFA, mark the browser as trusted for N days — MFA skipped on subsequent logins from that device.
|
|
- Store a signed `trusted_device` cookie (HMAC of device fingerprint + user_id + expiry)
|
|
- Server verifies on login; if valid, skip MFA challenge
|
|
- User can revoke trusted devices in Account Settings
|
|
|
|
### Medium priority — security / UX
|
|
|
|
**4. Email notifications**
|
|
Send email on security events: new login from unknown IP, MFA disabled, master password changed, emergency access requested.
|
|
- Requires SMTP config in `.env` (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`)
|
|
- Use Flask-Mail or smtplib directly
|
|
- User can opt out per event type in Account Settings
|
|
|
|
**5. Audit log improvements**
|
|
Current audit log is a raw list. Add:
|
|
- Filter by action type and date range
|
|
- Export to CSV
|
|
- Pagination (currently loads all at once)
|
|
|
|
**6. Access from unknown IP alert**
|
|
On login, compare client IP against last-seen IPs for the user. If new, flag in the audit log and (if email configured) send a notification.
|
|
|
|
### Lower priority — platform
|
|
|
|
**7. Vault item attachments**
|
|
Encrypt files client-side (AES-256-GCM), store as base64 blobs attached to vault items.
|
|
- New DB table: `vault_attachments` (item_id, enc_data, iv, filename_enc, iv_filename, size, created_at)
|
|
- Client-side size limit (e.g. 5 MB per file, 25 MB per vault)
|
|
- Download: decrypt in browser, trigger download via Blob URL
|
|
|
|
**8. Collections / nested folders**
|
|
Group folders into top-level collections (e.g. "Work", "Personal").
|
|
- New DB table: `collections` (user_id, name)
|
|
- `folders.collection_id` FK
|
|
- Sidebar renders collection → folder → items hierarchy
|
|
|
|
**9. iOS AutoFill Extension**
|
|
Expose PassKeeper as an iOS Credential Provider so it fills in Safari and other apps natively.
|
|
- Requires iOS Credential Provider Extension target in Xcode
|
|
- Shares encrypted vault data with the extension via App Groups
|
|
- Ties into the existing JQC iOS project infrastructure
|
|
|
|
**10. Extension — TOTP QR capture**
|
|
Detect 2FA setup QR codes on screen during account registration flows.
|
|
- Content script watches for `<img>` or `<canvas>` elements containing `otpauth://` URIs
|
|
- Prompts user to save the TOTP secret to the matching vault item
|
|
|
|
**11. Admin panel** (multi-user deployments)
|
|
- User management (invite, disable, delete)
|
|
- Per-user storage and audit log review
|
|
- Requires role column on `users` table
|
|
|
|
### Infrastructure
|
|
|
|
**12. Backup verification cron**
|
|
Extend `backup.cron` to run a weekly restore test into a throwaway DB, verifying backup integrity automatically.
|
|
|
|
**13. Health check endpoint**
|
|
`GET /health` — returns `{"status": "ok", "db": "ok", "version": "x.y.z"}`. Used by uptime monitors (UptimeRobot, etc.).
|
|
|
|
---
|
|
|
|
## CI Pipeline
|
|
|
|
`.gitea/workflows/ci.yml` runs on every push to `main`, `master`, `dev`.
|
|
|
|
**Jobs:**
|
|
1. `lint-python` — flake8 (F821 and real errors; style noise suppressed)
|
|
2. `syntax-check` — `ast.parse` all `app/` Python files
|
|
3. `migration-check` — single Alembic head, no duplicate revision IDs
|
|
4. `js-syntax` — `node -e "new Function(...)"` on all vault/extension JS files
|
|
4b. `tests` — pytest suite against in-memory SQLite (`TestingConfig`); gates `build-extension`
|
|
5. `build-extension` — produces `passkeeper-extension-chrome.zip` and `passkeeper-extension-firefox.zip` as artifacts (30-day retention)
|
|
|
|
**Runner:** self-hosted host-mode runner on the production server. Requires `python3`, `pip3`, `node`, `zip` on the host. No Docker needed. |