Aug 26 - Enhance security 2
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
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
This commit is contained in:
@@ -109,6 +109,16 @@ passkeeper/
|
||||
│ ├── 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_deploy_config.py # nginx/gunicorn/systemd invariants (502 guards)
|
||||
├── gunicorn.conf.py # worker class, timeouts, preload_app=False
|
||||
├── pytest.ini
|
||||
├── requirements-dev.txt
|
||||
├── reset_db.py
|
||||
├── requirements.txt
|
||||
├── wsgi.py / run.py
|
||||
@@ -135,7 +145,9 @@ CREATE TABLE users (
|
||||
sharing_private_key_enc TEXT,
|
||||
sharing_private_key_iv VARCHAR(64),
|
||||
recovery_enc_salt VARCHAR(128),
|
||||
recovery_iv VARCHAR(64)
|
||||
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
|
||||
@@ -225,6 +237,9 @@ CREATE TABLE webauthn_credentials (
|
||||
| `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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -238,7 +253,14 @@ CREATE TABLE webauthn_credentials (
|
||||
- **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
|
||||
- **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
|
||||
@@ -246,6 +268,24 @@ CREATE TABLE webauthn_credentials (
|
||||
- **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
|
||||
- **Audit logs:** never contain plaintext item names, shared item names, or vault data
|
||||
@@ -590,6 +630,12 @@ Audit log details **never** contain plaintext item names, shared item names, or
|
||||
- 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
|
||||
- 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
|
||||
- `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)
|
||||
|
||||
---
|
||||
@@ -785,6 +831,15 @@ page itself.
|
||||
|
||||
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
|
||||
- pytest suite (37 tests) + CI job; `gunicorn.conf.py`; systemd watchdog removed
|
||||
|
||||
### High priority — user-facing
|
||||
|
||||
**1. One-time share links**
|
||||
@@ -872,6 +927,7 @@ Extend `backup.cron` to run a weekly restore test into a throwaway DB, verifying
|
||||
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.
|
||||
Reference in New Issue
Block a user