31 KiB
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, extensions, CSRF exemptions, security headers)
│ ├── config.py # DevelopmentConfig / ProductionConfig (TOTP_ENCRYPTION_KEY, CORS_ORIGINS, RATELIMIT_STORAGE_URI)
│ ├── models/
│ │ ├── user.py # User model (Argon2id, TOTP encrypted, ECDH sharing keys, recovery columns)
│ │ ├── vault_item.py # VaultItem model (enc_data, iv, enc_name, iv_name — all sensitive fields encrypted)
│ │ ├── folder.py # Folder model
│ │ ├── token_blacklist.py # JWT revocation on logout (jti + expires_at)
│ │ ├── shared_item.py # Cross-user ECDH-encrypted item shares
│ │ ├── emergency_access.py # Emergency access grants (state machine)
│ │ └── audit_log.py # Server-side audit trail for all CUD actions
│ ├── routes/
│ │ ├── auth.py # Register, login, MFA/TOTP, logout, refresh, change-password, delete-account, recovery
│ │ ├── vault.py # CRUD vault items (accepts enc_name/iv_name on create/update)
│ │ ├── folders.py # CRUD folders
│ │ ├── sharing.py # ECDH key exchange + zero-knowledge item sharing
│ │ └── emergency.py # Emergency access state machine
│ ├── services/
│ │ └── auth_service.py # Argon2id hashing, JWT generation, blacklist, @require_jwt, TOTP encrypt/decrypt
│ ├── static/
│ │ ├── css/app.css # Full responsive stylesheet (phone/tablet/laptop/PC breakpoints)
│ │ └── js/
│ │ ├── crypto.js # Web Crypto API: deriveAuthHash, deriveVaultKey, encryptItem, decryptItem,
│ │ │ # encryptName, decryptName, generateSalt
│ │ ├── auth.js # Login/register forms + TOTP MFA step + VaultSession
│ │ ├── recover.js # Account recovery flow (two-step: verify code → set new password)
│ │ ├── sharing.js # ECDH P-256 key generation, encrypt/decrypt for sharing
│ │ └── vault.js # Vault UI: all views + sidebar toggle + change-password + recovery + delete-account
│ │ # renderSecurityDashboard is async — runs HIBP k-anonymity checks after sync sections
│ └── templates/
│ ├── base.html # CSP meta, csrf-token meta, viewport meta, no inline scripts/styles
│ ├── auth/
│ │ ├── login.html # Login + MFA step + "Forgot password?" link
│ │ ├── register.html
│ │ └── recover.html # Two-step account recovery page
│ └── vault/
│ └── index.html # Sidebar (collapsible), mobile topbar, vault list, modals
├── extension/ # Browser extension (Manifest V3)
│ ├── manifest.json # MV3: permissions, content_scripts, background, action
│ ├── background.js # Service worker: badge count, save-credentials relay, SSO sync,
│ │ # KEEPALIVE ping handler, OPEN_VAULT tab opener
│ ├── shared/
│ │ └── crypto.js # Same PBKDF2+AES-GCM logic; extractable vault key for session storage;
│ │ # encryptName / decryptName helpers for item name encryption
│ ├── popup/
│ │ ├── popup.html # Login → MFA → Unlock-only → Vault → Generator → Add Item views
│ │ ├── popup.css # Includes generator styles, save-modal overlay, pk-flyout context menu
│ │ └── popup.js # Auth, vault fetch/decrypt (decrypts enc_name), tabs, autofill trigger,
│ │ # inline password generator (fully CSPRNG via _cryptoRandInt),
│ │ # save-prompt modal, copy-username button, three-dot flyout menu,
│ │ # SSO, service-worker keepalive, relevant-tab domain filter
│ ├── content/
│ │ └── content.js # Form detection; _isLikelyUsernameField heuristic (autocomplete/name/id scoring);
│ │ # injects PK icon into username AND password fields only;
│ │ # debounced input handler (150ms); MutationObserver guards against
│ │ # re-triggering on extension's own DOM mutations;
│ │ # _normaliseUrl handles bare domains ("github.com")
│ ├── bridge/
│ │ └── bridge.js # SSO bridge (runs on vault domain only): syncs session web↔extension
│ ├── icons/ # Generated by make_icons.py (Pillow)
│ │ ├── icon16.png
│ │ ├── icon48.png
│ │ └── icon128.png
│ └── make_icons.py # Re-run to regenerate icons: python extension/make_icons.py
├── migrations/
│ ├── env.py # Alembic env (Flask-Migrate)
│ ├── script.py.mako
│ └── versions/
│ ├── 71d7158dd3b9_add_audit_log_tables_fix_sharing_public_.py
│ ├── a1b2c3d4e5f6_encrypt_totp_secret_at_rest.py # Widens totp_secret + adds totp_iv
│ ├── b2c3d4e5f6a7_add_account_recovery_columns.py # Adds recovery_enc_salt + recovery_iv
│ └── c3d4e5f6a7b8_encrypt_vault_item_name.py # Adds enc_name + iv_name to vault_items
├── scripts/
│ ├── reencrypt_totp_secrets.py # One-time migration: encrypt existing plaintext TOTP secrets
│ ├── backup_db.sh # Automated MySQL backup (gzip, 30-day retention)
│ ├── backup.cron # Crontab entry for daily 2 AM backup
│ ├── passkeeper-logrotate # logrotate config for Gunicorn logs
│ ├── passkeeper-nginx.conf # Hardened Nginx config (HSTS, rate limiting, upstream retry)
│ └── passkeeper.service # Hardened systemd unit (watchdog, preload, sandboxing)
├── reset_db.py # Drop + recreate all tables (dev only, handles FK checks)
├── requirements.txt
├── wsgi.py # Gunicorn entry point
├── run.py # Dev entry point
├── .env
└── CLAUDE.md
Database Schema (MySQL)
-- Users
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
master_hash VARCHAR(255) NOT NULL, -- Argon2id hash of client-derived PBKDF2 auth_hash
enc_key_salt VARCHAR(64) NOT NULL, -- Random 16-byte salt (base64), returned on login
created_at DATETIME DEFAULT NOW(),
last_login DATETIME,
-- TOTP / MFA (secret AES-256-GCM encrypted at rest)
totp_secret VARCHAR(255), -- AES-256-GCM ciphertext of base32 secret (base64)
totp_iv VARCHAR(64), -- base64 12-byte GCM nonce for totp_secret
totp_enabled TINYINT(1) DEFAULT 0,
-- ECDH P-256 sharing keypair
sharing_public_key VARCHAR(128), -- Raw uncompressed point (65 bytes), base64, plaintext
sharing_private_key_enc TEXT, -- JWK, AES-256-GCM encrypted with vault key
sharing_private_key_iv VARCHAR(64), -- base64 12-byte nonce for private key encryption
-- Account recovery
recovery_enc_salt VARCHAR(128), -- enc_key_salt re-encrypted with recovery key (base64)
recovery_iv VARCHAR(64) -- base64 12-byte nonce for recovery_enc_salt
);
-- Folders
CREATE TABLE folders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
name VARCHAR(128) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Vault Items (all sensitive fields AES-256-GCM encrypted client-side)
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', -- plain string, not ENUM
name VARCHAR(255) NOT NULL, -- non-sensitive server label (item type only)
enc_data TEXT NOT NULL, -- base64 AES-256-GCM ciphertext of item payload
iv VARCHAR(64) NOT NULL, -- base64 12-byte GCM nonce for enc_data
enc_name TEXT, -- base64 AES-256-GCM ciphertext of item name
iv_name VARCHAR(64), -- base64 12-byte GCM nonce for enc_name
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
);
-- Token Blacklist (JWT revocation)
CREATE TABLE token_blacklist (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
jti VARCHAR(36) UNIQUE NOT NULL,
user_id INT UNSIGNED NOT NULL,
expires_at DATETIME NOT NULL,
INDEX (jti), INDEX (expires_at)
);
-- Shared Items (ECDH zero-knowledge sharing)
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, -- plaintext display name for share inbox
item_type VARCHAR(20) NOT NULL,
enc_data TEXT NOT NULL,
iv VARCHAR(64) NOT NULL,
accepted TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT NOW(),
FOREIGN KEY (item_id) REFERENCES vault_items(id) ON DELETE CASCADE
);
-- Emergency Access
CREATE TABLE emergency_access (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
grantor_id INT UNSIGNED NOT NULL,
grantee_email VARCHAR(255) NOT NULL,
grantee_id INT UNSIGNED,
wait_days INT NOT NULL DEFAULT 7,
status VARCHAR(20) NOT NULL DEFAULT 'invited',
request_initiated_at DATETIME,
enc_vault TEXT,
created_at DATETIME DEFAULT NOW(),
FOREIGN KEY (grantor_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (grantee_id) REFERENCES users(id) ON DELETE SET NULL
);
-- Audit Logs
CREATE TABLE audit_logs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
resource_type VARCHAR(64) NOT NULL,
resource_id INT UNSIGNED,
detail VARCHAR(512),
ip_address VARCHAR(45),
created_at DATETIME NOT NULL,
INDEX (user_id), INDEX (created_at)
);
Security Model
- Zero-knowledge: master password never sent to server
authHash = PBKDF2(masterPassword, email, 100_000 iter)— sent to server for auth onlyvaultKey = PBKDF2(masterPassword, enc_key_salt, 600_000 iter)— stays in browser memory only- All vault data (payload + item name) encrypted client-side (AES-256-GCM) before sending to server
- Item name encryption:
enc_name/iv_namestore the AES-256-GCM ciphertext of the item name. The server's plaintextnamecolumn holds only the item type (e.g."password") as a non-sensitive audit label. Legacy items withoutenc_namefall back tonametransparently. - Server-side: Argon2id hash of the client-derived
authHash(double-hashed for depth) - JWT: HS256, access token 15 min, refresh token 7 days, unique JTI per token
- JWT blacklist: on logout both tokens are blacklisted by JTI; refresh rotates tokens
- MFA: TOTP (pyotp), secret stored AES-256-GCM encrypted in
users.totp_secret/totp_iv - Sharing: ECDH P-256 — shared secret derived client-side, re-encrypts item plaintext
- Emergency access: same ECDH re-encryption; wait timer enforced server-side
- CSRF: Flask-WTF on page-serving routes; API blueprints CSRF-exempt (JWT bearer auth)
- Rate limiting: Flask-Limiter (Redis-backed in production); Nginx dual-zone as second layer
- HTTP security headers: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP via
@app.after_request - Password generator (extension): Fully CSPRNG —
_cryptoRandInt()uses rejection-sampling withcrypto.getRandomValues().Math.random()is never called anywhere in the generator. - Decrypted vault data in extension: Stored in
chrome.storage.sessiononly (memory-only, cleared on browser close). Never written tochrome.storage.local. - Breach detection: Security dashboard runs HaveIBeenPwned k-anonymity checks — only the first 5 chars of each password's SHA-1 hash are transmitted. Passwords never leave the browser.
Extension Storage Architecture — Critical Rules
Storage area decision table
| Data | Where stored | Why |
|---|---|---|
access_token, vault_key_jwk, vault_items, vault_items_cs |
chrome.storage.session |
Memory-only, cleared on browser close. Decrypted data must never persist to disk. |
refresh_token, enc_key_salt, pending_save, save_blocklist, idle_lock_seconds |
chrome.storage.local |
Persists across browser restarts and SW termination. |
vault_items_cs is in chrome.storage.session, NOT chrome.storage.local
As of the current codebase, vault_items_cs (the decrypted vault cache for content scripts) is written to chrome.storage.session. This is a deliberate security decision — decrypted passwords must never be persisted to disk.
Minimum Chrome version required: 111 (March 2023). chrome.storage.session is not readable by content scripts on Chrome ≤ 110. The storage.onChanged listener in content.js watches area === 'session'.
⚠️ The original CLAUDE.md stated
vault_items_cslives inchrome.storage.local. That is no longer correct. If you see it reverting tolocal, that is a regression — revert it.
chrome.storage.onChanged area guard
chrome.storage.onChanged.addListener(function(changes, area) {
if (area === 'session' && changes.vault_items_cs) { // 'session', not 'local'
...
}
});
pending_save must remain in chrome.storage.local
chrome.storage.session is wiped when the MV3 service worker is killed (within seconds of inactivity). pending_save stays in local so the save prompt survives SW restarts.
Key Implementation Decisions & Gotchas
Item name is encrypted client-side (enc_name / iv_name)
The name column in vault_items is not sensitive — it stores only the item type string (e.g. "password") as a server-side audit label. The real display name is stored as AES-256-GCM ciphertext in enc_name with nonce iv_name.
On load: loadVault() / fetchAndDecryptVault() decrypt enc_name after decrypting enc_data and override item.name with the plaintext. Legacy items without enc_name use the server's name field as fallback — no migration of existing rows required.
On save: handleFormSubmit() (web app) and addItemToVault() / saveCredential() (extension) call Crypto.encryptName(vaultKey, name) / ExtCrypto.encryptName(vaultKey, name) before POSTing, sending enc_name + iv_name alongside enc_data + iv.
On password change / recovery: The re-encryption loops in auth.py also update vault_item.enc_name / vault_item.iv_name when the client sends updated values.
API contract: POST /api/vault and PUT /api/vault/<id> now accept optional enc_name and iv_name fields. The name field is still required (used as the server-side label).
Password generator is fully CSPRNG
generatePassword() in extension/popup/popup.js uses _cryptoRandInt(max) for all random selection:
function _cryptoRandInt(max) {
// Rejection sampling — no modulo bias.
const limit = Math.floor(0x100000000 / max) * max;
const buf = new Uint32Array(1);
do { crypto.getRandomValues(buf); } while (buf[0] >= limit);
return buf[0] % max;
}
This replaces Math.floor(Math.random() * n) which was used in the required-character selection and Fisher-Yates shuffle steps. Math.random() is not called anywhere in the generator.
_isLikelyUsernameField() — credential field detection heuristic
findUsernameField() in content.js no longer accepts any input[type="text"] preceding the password field. It scores candidates via _isLikelyUsernameField():
- Definite YES:
autocomplete="username"or"email"or"tel" - Definite NO: Non-credential
autocompletevalues (name,given-name,organization,search, etc.) - Keyword scan YES:
name,id,placeholder, oraria-labelcontainuser|email|mail|login|phone|tel|mobile|account - Otherwise: field is not decorated
input[type="email"] and input[type="tel"] are always accepted without scoring.
_normaliseUrl() — bare-domain URL matching
Both content.js and popup.js include a _normaliseUrl() helper that prepends https:// to URLs that lack a scheme before passing them to new URL(). This prevents silent match failures for vault items stored with bare domains (e.g. "github.com" instead of "https://github.com").
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;
}
MutationObserver guards against extension's own DOM mutations
The MutationObserver in content.js inspects every mutation before calling decorateFields(). If every added/removed node carries a __pk class or id prefix, the callback returns early — preventing re-decoration loops on SPAs triggered by the extension injecting or removing its own dropdown or icon buttons.
showDropdown is debounced on input events
The input event listener on decorated fields wraps showDropdown with a 150 ms debounce via _debounce(). The focus listener remains instant. This eliminates visible lag on large vaults when the user types quickly.
Three-dot menu is now a proper flyout
The data-menu button in the popup renders a .pk-flyout div anchored below the button. Menu items are built dynamically from what the item has — "Open URL" only if plain.url exists, etc. Styled via .pk-flyout / .pk-flyout-item in popup.css. Closes on any outside click.
Copy-username button is a dedicated action
A data-copy-user button with a person SVG icon appears at the left of the action row for any item with a username. This replaces the previous behaviour where the three-dot button silently copied the username on click.
HaveIBeenPwned breach detection
renderSecurityDashboard() in vault.js is now async. After rendering the synchronous sections (Weak / Reused / Old), it fires parallel HIBP API calls for every password item via checkHibp(password):
async function checkHibp(password) {
const hashHex = /* SHA-1 of password via crypto.subtle */;
const prefix = hashHex.slice(0, 5);
const suffix = hashHex.slice(5);
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { 'Add-Padding': 'true' },
});
// parse response, find matching suffix, return breach count
}
Only the first 5 hex characters of the SHA-1 hash are sent. Passwords never leave the browser. Network errors fail safe (return 0, do not block UI). The HIBP section renders progressively after the synchronous sections.
item_type must be VARCHAR(20), NOT db.Enum(ItemType)
Using db.Enum(ItemType) with a str-based Python enum caused SQLAlchemy to lazy-load the column back as a raw string after commit, making self.item_type.value raise AttributeError. Use db.String(20).
INTEGER(unsigned=True) requires MySQL dialect type
from sqlalchemy.dialects.mysql import INTEGER
db.Column(INTEGER(unsigned=True), ...)
All datetimes are naive UTC (datetime.utcnow())
Do NOT mix in datetime.now(timezone.utc) — mixing causes comparison failures in token blacklist and emergency access wait-timer checks.
AuditLog must flush before log, then commit together
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() # commits both atomically
For deletes, capture id and name before the delete flush.
Extension vault key must be extractable
extension/shared/crypto.js uses extractable: true so the vault key can be serialised as JWK and stored in chrome.storage.session. The web app uses extractable: false.
JWT sub claim must be a string (PyJWT 2.x)
Always convert: 'sub': str(user_id) when generating tokens; int(payload['sub']) when reading back.
TOTP secrets encrypted at rest (server-side)
totp_secret stores AES-256-GCM ciphertext; totp_iv stores the nonce. Encrypted/decrypted exclusively by auth_service.encrypt_totp_secret() / auth_service.decrypt_totp_secret(). Generating the key:
python -c "import secrets; print(secrets.token_hex(32))"
Extension SSO bridge
bridge.js runs on pwkeeper.ngodanguyen.tech only and syncs sessions bidirectionally between the web app and the extension. See the original CLAUDE.md for the full bridge flow.
Autofill uses native HTMLInputElement setter for framework compatibility
const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
if (nativeSet) nativeSet.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
reset_db.py for schema changes (Windows, no flask db CLI)
python reset_db.py
Production schema changes: flask db upgrade.
Audit Log System
Every create, edit, and delete action is recorded server-side via AuditLog.log(). No sensitive or encrypted values are ever written to the audit log.
Audit Action Catalog
| Route module | Action string | Trigger |
|---|---|---|
auth.py |
auth.register |
New account created |
auth.py |
auth.login |
Successful password verification |
auth.py |
auth.login_failed |
Failed password attempt (known email) |
auth.py |
auth.mfa_enable |
TOTP enabled after verification |
auth.py |
auth.mfa_disable |
TOTP disabled after verification |
auth.py |
auth.mfa_verify |
MFA step completed, session tokens issued |
auth.py |
auth.change_password |
Master password changed, vault re-encrypted |
auth.py |
auth.change_password_failed |
Password change rejected — wrong current password |
auth.py |
auth.delete_account |
Account permanently deleted |
auth.py |
auth.delete_account_failed |
Deletion rejected — wrong password |
auth.py |
auth.recovery_setup |
Recovery code configured |
auth.py |
auth.recovery_failed |
Recovery attempt — incorrect code |
auth.py |
auth.recovery_items_denied |
Recovery items fetch — incorrect proof |
auth.py |
auth.recovery_success |
Account recovered, vault re-encrypted |
vault.py |
vault_item.create |
Vault item created |
vault.py |
vault_item.update |
Vault item updated |
vault.py |
vault_item.delete |
Vault item deleted |
folders.py |
folder.create |
Folder created |
folders.py |
folder.update |
Folder renamed |
folders.py |
folder.delete |
Folder deleted |
sharing.py |
sharing_keys.create |
ECDH keypair stored for first time |
sharing.py |
sharing_keys.update |
ECDH keypair replaced |
sharing.py |
shared_item.create |
Item shared with another user |
sharing.py |
shared_item.delete |
Share revoked by owner |
sharing.py |
shared_item.accept |
Recipient accepted a share |
emergency.py |
emergency_access.create |
Emergency access invitation sent |
emergency.py |
emergency_access.delete |
Emergency access grant removed |
emergency.py |
emergency_access.accept |
Grantee accepted invitation |
emergency.py |
emergency_access.provide_vault |
Grantor uploaded encrypted vault snapshot |
emergency.py |
emergency_access.request |
Grantee initiated access request |
emergency.py |
emergency_access.deny |
Grantor denied pending request |
emergency.py |
emergency_access.vault_retrieved |
Grantee fetched vault after wait elapsed |
REST API Endpoints
All /api/vault/* and /api/folders/* routes require Authorization: Bearer <access_token>.
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
POST /api/auth/refresh
GET /api/auth/me
GET /api/auth/mfa/status
GET /api/auth/mfa/setup
POST /api/auth/mfa/enable
POST /api/auth/mfa/disable
POST /api/auth/mfa/verify
POST /api/auth/change-password # { current_auth_hash, new_auth_hash, new_enc_key_salt,
# items: [{id, enc_data, iv, enc_name?, iv_name?}] }
DELETE /api/auth/account
POST /api/auth/recovery/setup
GET /api/auth/recovery/status
GET /api/auth/recovery/data
GET /api/auth/recovery/items
POST /api/auth/recover
GET /api/vault
POST /api/vault # { name, item_type, folder_id, enc_data, iv, enc_name?, iv_name? }
GET /api/vault/<id>
PUT /api/vault/<id> # accepts enc_name, iv_name
DELETE /api/vault/<id>
GET /api/folders
POST /api/folders
PUT /api/folders/<id>
DELETE /api/folders/<id>
GET /api/sharing/keys
POST /api/sharing/keys
GET /api/sharing/public-key
GET /api/sharing
POST /api/sharing
DELETE /api/sharing/<id>
GET /api/sharing/inbox
POST /api/sharing/inbox/<id>/accept
GET /api/emergency
POST /api/emergency
DELETE /api/emergency/<id>
POST /api/emergency/<id>/accept
POST /api/emergency/<id>/provide
POST /api/emergency/<id>/request
POST /api/emergency/<id>/deny
GET /api/emergency/<id>/vault
Development Setup (Windows)
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
# Edit .env: MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB, 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 (Ubuntu + Nginx + Gunicorn + systemd)
# Schema migrations (all subsequent deployments)
flask db upgrade
# Reload app
sudo systemctl reload passkeeper
For full initial setup instructions (Nginx config, systemd unit, Certbot, etc.) refer to the original deployment guide in this file's history or scripts/passkeeper-nginx.conf / scripts/passkeeper.service.
Python Dependencies (requirements.txt)
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 # AES-256-GCM server-side TOTP secret encryption
redis>=5.0 # Shared rate-limit storage across Gunicorn workers
Migration History
| Revision | Description |
|---|---|
71d7158dd3b9 |
Add audit_logs table; fix sharing_public_key column type |
a1b2c3d4e5f6 |
Encrypt TOTP secret at rest (totp_secret → VARCHAR(255), add totp_iv) |
b2c3d4e5f6a7 |
Add account recovery columns (recovery_enc_salt, recovery_iv) |
c3d4e5f6a7b8 |
Encrypt vault item name (add enc_name, iv_name to vault_items) |
Notes
- Never log decrypted vault data server-side
- All encryption/decryption of vault data (including item names) happens in the browser
- The server stores only encrypted blobs — zero-knowledge architecture
enc_key_saltis not secret on its own — only useful combined with the master password- Redis is required in production for rate limiting. Without it, each Gunicorn worker maintains its own counter.
- Recovery code is never stored server-side — only the AES-256-GCM ciphertext of
enc_key_salt - Password change clears the recovery code — user must regenerate from Account Settings
- The
#vault-listelement ID must not be renamed —vault.jsrenders all vault items into it save_blocklistinchrome.storage.localstores hostnames for which the save banner is suppressedvault_items_csis inchrome.storage.session— decrypted data never written to disk (changed fromlocal)- HIBP checks run progressively — the security dashboard renders synchronous sections first, then fires parallel k-anonymity requests in the background