Aug 26 - Update password detect against off field 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

This commit is contained in:
2026-08-26 15:05:48 -04:00
parent 2f1afb143c
commit 0d7d9c1403
12 changed files with 677 additions and 99 deletions
+49 -3
View File
@@ -53,6 +53,7 @@ passkeeper/
│ │ ├── 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)
│ │ ├── login_attempt.py # Failed-login lockout scoped to (user, IP)
│ │ └── audit_log.py
│ ├── routes/
│ │ ├── auth.py # Register, login, MFA, logout, refresh, change-password, recovery
@@ -120,6 +121,7 @@ passkeeper/
│ ├── 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_login_lockout.py # per-IP lockout; no disclosure, no DoS
│ ├── test_deploy_config.py # nginx/gunicorn/systemd/extension packaging guards
│ └── js/
│ ├── test_psl.js # PSL same-site matching (node, run in CI)
@@ -249,6 +251,7 @@ CREATE TABLE webauthn_credentials (
| `j0k1l2m3n4o5` | Add recovery_verifier (decouple recovery proof) |
| `k1l2m3n4o5p6` | Add token_epoch (revoke sessions on pw change) |
| `l2m3n4o5p6q7` | Add emergency vault retrieval tracking |
| `m3n4o5p6q7r8` | Add login_attempts (per-IP lockout) |
---
@@ -261,6 +264,13 @@ CREATE TABLE webauthn_credentials (
- **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
- **Login lockout:** scoped to (account, source IP) in `login_attempts`, NOT
global. A global counter made it a DoS primitive — anyone knowing an address
could lock the real owner out for 15 minutes, repeatedly. Every failure mode
(unknown account / wrong password / locked out) returns one identical 401 with
matching timing, so it discloses nothing. `users.failed_login_count` and
`locked_until` remain as an aggregate audit signal only; they no longer gate
authentication.
- **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`;
@@ -647,6 +657,35 @@ is ever dispatched and the save-credentials banner never appeared.
`_captureCooldown` (2 s) prevents two triggers double-prompting for one login.
### Insecure-page warning
The extension keeps `http://*/*` permission deliberately: routers, NAS boxes,
printers and self-hosted panels are often reachable only over plain HTTP on the
LAN, and those are exactly the devices whose passwords get reused.
`_isTrustworthyOrigin()` classifies the page. HTTPS, `file:`, `localhost`,
reserved TLDs (`.local` / `.lan` / `.home` / `.internal`) and RFC1918 /
loopback / link-local / RFC4193 addresses are accepted silently. Any other
`http://` origin gets a red warning row prepended to the suggestion dropdown.
**IPv4 checks must match in FULL** (`$`-anchored). A prefix test like
`hostname.startsWith("127.")` also accepts the registrable
`127.0.0.1.evil.com`, which would silently suppress the warning on a hostile
site. Guarded by `tests/js/test_field_heuristics.js`.
Filling is never automatic, so this warns rather than blocks — silently
offering nothing would just look like a broken extension.
### Autologin without a `<form>`
`_findSubmitControl(pwField)` locates the control that submits the login,
walking up to 5 ancestors when there is no `<form>`. It skips invisible
elements, wrappers containing other inputs, labels over 40 chars, and anything
matching `_NEGATIVE_CONTROL` (cancel / reset / back / forgot / register / sign
up), then clicks it — `form.submit()` is only the last resort because it
bypasses site handlers entirely. Returns null when nothing is convincing;
leaving a filled form for the user beats clicking the wrong thing.
### 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.
@@ -682,7 +721,7 @@ Audit log details **never** contain plaintext item names, shared item names, or
- `#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
- APScheduler cleanup job handles `TokenBlacklist`, `RecoveryChallenge`, `TotpUsedCode`, expired shares AND `LoginAttempt`; 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
@@ -690,6 +729,9 @@ Audit log details **never** contain plaintext item names, shared item names, or
- `/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
- Host/IP allowlists must be `$`-anchored — `startsWith("127.")` also matches the attacker-registrable `127.0.0.1.evil.com`
- `escHtml` must escape `&`, `<`, `>`, `"` AND `'` in all three copies (vault.js, popup.js, content.js) — templates mix quote styles
- Login lockout lives in `login_attempts` keyed by (user, IP); never move it back to a global per-account counter
- Never add `off` back to `NON_CRED_AC` in `content.js``tests/js/test_field_heuristics.js` fails the build if you do
- Login detection must not assume a `<form>` exists; route new capture triggers through `maybeCaptureCredentials()`
- Never reintroduce `endsWith("." + host)` host matching anywhere in the extension — `tests/test_deploy_config.py` fails the build if it reappears
@@ -711,7 +753,7 @@ Audit log details **never** contain plaintext item names, shared item names, or
| `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.account_locked` / `auth.login_blocked` | Per-IP lockout triggered / hit |
| `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 |
@@ -908,8 +950,12 @@ Features planned for future implementation. Ordered by priority within each cate
- 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)
- Login lockout scoped per-IP: no account disclosure, no lock-out-the-owner DoS
- `escHtml` escapes single quotes; dead `User.check_password` removed
- Extension warns before filling on plaintext public pages; autologin works
without a `<form>`
- Emergency access: requests and vault retrievals are now visible to the grantor
- pytest suite (66 tests) + PSL node test + CI jobs; `gunicorn.conf.py`;
- pytest suite (77 tests) + 2 node test files + CI jobs; `gunicorn.conf.py`;
systemd watchdog removed
### High priority — user-facing