395 lines
17 KiB
Markdown
395 lines
17 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)
|
|
│ │ ├── shared_item.py # ECDH-encrypted cross-user shares
|
|
│ │ ├── emergency_access.py # State machine
|
|
│ │ └── 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
|
|
│ ├── services/
|
|
│ │ └── auth_service.py # Argon2id, JWT, blacklist, @require_jwt, TOTP encrypt/decrypt
|
|
│ ├── 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
|
|
│ │ └── 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); Ctrl+Shift+L keyboard shortcut
|
|
│ ├── manifest.firefox.json # MV2 (Firefox)
|
|
│ ├── 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
|
|
│ │ └── 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 # adds enc_name + iv_name
|
|
├── scripts/
|
|
│ ├── reencrypt_totp_secrets.py
|
|
│ ├── backup_db.sh / backup.cron / passkeeper-logrotate
|
|
│ ├── passkeeper-nginx.conf / passkeeper.service
|
|
├── 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,
|
|
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)
|
|
);
|
|
|
|
-- 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
|
|
);
|
|
-- (folders, token_blacklist, shared_items, emergency_access, audit_logs — unchanged)
|
|
```
|
|
|
|
---
|
|
|
|
## 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 |
|
|
|
|
---
|
|
|
|
## 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`; server `name` column = item type only
|
|
- **Tags:** `plain.tags: string[]` inside `enc_data`; server never sees them
|
|
- **Argon2id:** double-hashes `authHash` server-side
|
|
- **JWT:** HS256, 15 min access / 7 day refresh, JTI blacklisted on logout
|
|
- **MFA:** TOTP secret AES-256-GCM encrypted at rest
|
|
- **Sharing:** ECDH P-256 zero-knowledge re-encryption
|
|
- **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
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
### 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.
|
|
- To favourite an item: add tag `favorite` in the web app edit modal.
|
|
|
|
### 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:** parses Chrome / Bitwarden / 1Password formats; encrypts client-side before POSTing.
|
|
- Both endpoints write audit logs: `vault_item.export`, `vault_item.import`.
|
|
|
|
### 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`.
|
|
|
|
### `_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, ...)
|
|
db.session.commit() # atomic
|
|
```
|
|
For deletes: capture `id` and `name` before flush.
|
|
|
|
### 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.utcnow()` — 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
|
|
|
|
---
|
|
|
|
## Audit Action Catalog
|
|
|
|
| Module | Action | Trigger |
|
|
|---|---|---|
|
|
| `auth.py` | `auth.register` | New account |
|
|
| `auth.py` | `auth.login` / `auth.login_failed` | Login success/fail |
|
|
| `auth.py` | `auth.mfa_enable/disable/verify` | TOTP actions |
|
|
| `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 |
|
|
| `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 |
|
|
| `emergency.py` | `emergency_access.*` | All EA state transitions |
|
|
|
|
---
|
|
|
|
## 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|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
|
|
DELETE /api/sharing/<id>
|
|
GET /api/sharing/inbox
|
|
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
|
|
```
|
|
|
|
---
|
|
|
|
## 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)
|
|
```
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- Never log decrypted vault data server-side
|
|
- 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 |