diff --git a/CLAUDE.md b/CLAUDE.md index f7ae03b..9dd4011 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `