diff --git a/CLAUDE.md b/CLAUDE.md index 33eaf93..6090a3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,6 +118,8 @@ passkeeper/ │ ├── test_session_revocation.py # token_epoch revocation; deleted-account 401 │ ├── test_webauthn_uv.py # user verification required on both ceremonies │ ├── 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_deploy_config.py # nginx/gunicorn/systemd/extension packaging guards │ └── js/test_psl.js # PSL same-site matching (node, run in CI) ├── gunicorn.conf.py # worker class, timeouts, preload_app=False @@ -244,6 +246,7 @@ CREATE TABLE webauthn_credentials ( | `i9j0k1l2m3n4` | Add expires_at to shared_items | | `j0k1l2m3n4o5` | Add recovery_verifier (decouple recovery proof) | | `k1l2m3n4o5p6` | Add token_epoch (revoke sessions on pw change) | +| `l2m3n4o5p6q7` | Add emergency vault retrieval tracking | --- @@ -301,6 +304,20 @@ CREATE TABLE webauthn_credentials ( 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 +- **Registration privacy:** `POST /api/auth/register` returns an identical 202 + whether or not the address exists, and performs an equivalent Argon2id hash on + both branches so timing does not reinstate the oracle. Duplicate attempts are + audited under `auth.register_duplicate`. Fully closing this needs email + verification so the address owner is told — until then the oracle is removed + but the owner cannot be notified. +- **Emergency access visibility:** `accept`, `request` and `vault_retrieved` are + audited under BOTH parties' user_ids. `/api/auth/audit-log` filters by + `user_id`, so an entry written only under the acting user is invisible to the + other — which meant a grantee could request and retrieve a vault snapshot + without anything reaching the grantor. `vault_retrieved_at` / + `vault_retrieval_count` on `emergency_access` record every fetch. Retrieval is + intentionally NOT blocked after the first time (the grantor may be unable to + re-provision); the wait period is the gate, and the grantor can revoke. - **Audit logs:** never contain plaintext item names, shared item names, or vault data --- @@ -642,6 +659,8 @@ Audit log details **never** contain plaintext item names, shared item names, or - `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 +- Audit entries are only visible to the user_id they are written under — mirror cross-party events (use `_log_for_both` in `emergency.py`) or the other party never sees them +- `/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 - Never reintroduce `endsWith("." + host)` host matching anywhere in the extension — `tests/test_deploy_config.py` fails the build if it reappears @@ -661,6 +680,7 @@ Audit log details **never** contain plaintext item names, shared item names, or | Module | Action | Trigger | | -------------- | ------------------------------------------------------ | ------------------------------ | | `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.mfa_enable/disable/verify` | TOTP actions | @@ -676,6 +696,7 @@ Audit log details **never** contain plaintext item names, shared item names, or | `sharing.py` | `sharing_keys.create/update` | ECDH key setup | | `sharing.py` | `shared_item.create/delete/accept` | Sharing (detail: type + id) | | `emergency.py` | `emergency_access.*` | All EA state transitions | +| `emergency.py` | `emergency_access.accept/request/vault_retrieved` | Logged under BOTH grantor and grantee user_ids | | `webauthn.py` | `webauthn.register` | Passkey registered | | `webauthn.py` | `webauthn.auth_success` / `webauthn.auth_failed` | Passkey login attempt | | `webauthn.py` | `webauthn.rename` / `webauthn.delete` | Credential management | @@ -857,7 +878,9 @@ Features planned for future implementation. Ordered by priority within each cate - Autofill matching moved onto the Public Suffix List (registrable domains) - Share `expires_days` fails closed instead of silently meaning "never" - nginx `api_limit` corrected from 60r/m to 10r/s -- pytest suite (51 tests) + PSL node test + CI jobs; `gunicorn.conf.py`; +- Registration no longer discloses account existence (status, body and timing) +- Emergency access: requests and vault retrievals are now visible to the grantor +- pytest suite (66 tests) + PSL node test + CI jobs; `gunicorn.conf.py`; systemd watchdog removed ### High priority — user-facing diff --git a/app/models/emergency_access.py b/app/models/emergency_access.py index 146cf87..105c6a7 100644 --- a/app/models/emergency_access.py +++ b/app/models/emergency_access.py @@ -16,6 +16,10 @@ class EmergencyAccess(db.Model): pending → grantor calls /deny → ready (reset, grantee can request again) pending (wait_days elapsed) → grantable (grantee fetches vault) + Retrieval does not change `status`: the grant stays 'pending' so the grantor + keeps seeing it as active and can revoke it. What retrieval does change is + vault_retrieved_at / vault_retrieval_count, which the grantor's UI surfaces. + Zero-knowledge: enc_vault is a JSON array of vault items re-encrypted by the grantor using the ECDH shared secret (grantor private key + grantee public key). """ @@ -40,6 +44,12 @@ class EmergencyAccess(db.Model): # JSON string: [{ id, name, item_type, enc_data, iv }, ...] enc_vault = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) + # Retrieval tracking — makes grantee access to the snapshot visible to the + # grantor. Retrieval is not blocked after the first time (the grantor may be + # unable to re-provision, which is the entire premise of emergency access); + # the wait period is the gate, and these make use of it auditable. + vault_retrieved_at = db.Column(db.DateTime, nullable=True) + vault_retrieval_count = db.Column(db.Integer, default=0, nullable=False, server_default='0') @property def wait_elapsed(self): @@ -62,6 +72,10 @@ class EmergencyAccess(db.Model): self.request_initiated_at.isoformat() if self.request_initiated_at else None ), 'created_at': self.created_at.isoformat() if self.created_at else None, + 'vault_retrieved_at': ( + self.vault_retrieved_at.isoformat() if self.vault_retrieved_at else None + ), + 'vault_retrieval_count': self.vault_retrieval_count or 0, # True when enc_vault contains items in the old format (has a plaintext # 'name' field instead of enc_name/iv_name). Grantor should re-provision. 'enc_vault_is_legacy': self._enc_vault_is_legacy(), diff --git a/app/routes/auth.py b/app/routes/auth.py index 0545847..7b6079a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -131,7 +131,7 @@ def _apply_reencrypted_items(user_id: int, items, allow_partial: bool = False) - @auth_bp.route('/register', methods=['POST']) -@limiter.limit('10 per minute') +@limiter.limit('5 per minute') def register(): data = request.get_json(silent=True) or {} email = (data.get('email') or '').strip().lower() @@ -147,8 +147,46 @@ def register(): if not enc_key_salt: return jsonify({'error': 'enc_key_salt is required'}), 400 + # ── Account-existence must not be observable ──────────────────────────── + # + # This used to answer 409 "Email already registered", which let anyone probe + # whether a given address has a PassKeeper account — a useful target list for + # phishing, and exactly the kind of thing a password manager should not leak. + # + # Both branches now return the identical 202 body. The wording sends the user + # to the sign-in page either way, which is the correct next step in both + # cases: registering an address that already exists is harmless because the + # user simply signs in with the password they already have. + # + # Timing has to match too. Creating an account runs Argon2id (deliberately + # slow); returning early without it would make "exists" measurably faster and + # reinstate the oracle through the side door. So the existing-account branch + # performs and discards an equivalent hash. + # + # NOTE: fully closing this needs email verification (roadmap item 4) so the + # address owner is told when someone tries to register it. Until then this + # removes the oracle but cannot notify the legitimate owner. + generic_response = jsonify({ + 'message': ( + 'If that email address was available, your account has been created. ' + 'Please sign in.' + ) + }), 202 + + time.sleep(0.1) # flatten timing across both branches + if User.query.filter_by(email=email).first(): - return jsonify({'error': 'Email already registered'}), 409 + hash_auth_token(auth_hash) # equalise work; result intentionally discarded + AuditLog.log( + user_id=0, # no account to attribute this to + action='auth.register_duplicate', + resource_type='user', + resource_id=None, + detail='Registration attempted for an address that already exists', + ip_address=client_ip(), + ) + db.session.commit() + return generic_response master_hash = hash_auth_token(auth_hash) user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt) @@ -165,7 +203,7 @@ def register(): ) db.session.commit() - return jsonify({'message': 'Account created successfully'}), 201 + return generic_response @auth_bp.route('/login', methods=['POST']) diff --git a/app/routes/emergency.py b/app/routes/emergency.py index d7de4b7..a294bb4 100644 --- a/app/routes/emergency.py +++ b/app/routes/emergency.py @@ -41,6 +41,39 @@ def list_emergency(): }), 200 +def _log_for_both(ea: EmergencyAccess, action: str, grantor_detail: str, + grantee_detail: str) -> None: + """ + Write the audit entry twice — once under each party's user_id. + + /api/auth/audit-log filters by user_id, so an entry written only under the + acting user is invisible to the other party. That meant a grantee could + request access and retrieve the vault snapshot without a single line of it + appearing in the grantor's own audit log or security dashboard — the person + whose vault it was had no way to see it had happened. + + Until email notifications exist (roadmap item 4), the grantor's audit log is + the only channel that reaches them, so it must carry these events. + """ + AuditLog.log( + user_id=ea.grantor_id, + action=action, + resource_type='emergency_access', + resource_id=ea.id, + detail=grantor_detail, + ip_address=client_ip(), + ) + if ea.grantee_id and ea.grantee_id != ea.grantor_id: + AuditLog.log( + user_id=ea.grantee_id, + action=action, + resource_type='emergency_access', + resource_id=ea.id, + detail=grantee_detail, + ip_address=client_ip(), + ) + + def _ea_as_grantee(ea: EmergencyAccess, grantor: 'User | None' = None) -> dict: if grantor is None: grantor = db.session.get(User, ea.grantor_id) @@ -150,14 +183,13 @@ def accept_emergency(ea_id): ea.status = 'accepted' ea.grantee_id = user.id + db.session.flush() - AuditLog.log( - user_id=g.current_user_id, - action='emergency_access.accept', - resource_type='emergency_access', - resource_id=ea.id, - detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}', - ip_address=client_ip(), + _log_for_both( + ea, + 'emergency_access.accept', + grantor_detail=f'{ea.grantee_email} accepted your emergency access invitation', + grantee_detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}', ) db.session.commit() @@ -221,14 +253,21 @@ def request_access(ea_id): ea.status = 'pending' ea.request_initiated_at = datetime.now(timezone.utc).replace(tzinfo=None) + db.session.flush() - AuditLog.log( - user_id=g.current_user_id, - action='emergency_access.request', - resource_type='emergency_access', - resource_id=ea.id, - detail=f'Requested emergency vault access from grantor_id={ea.grantor_id} (wait: {ea.wait_days}d)', - ip_address=client_ip(), + # The grantor has `wait_days` to notice and deny this. If it only appeared in + # the grantee's audit log they would never see it in time. + _log_for_both( + ea, + 'emergency_access.request', + grantor_detail=( + f'ACTION REQUIRED: {ea.grantee_email} requested emergency access to ' + f'your vault. It unlocks in {ea.wait_days} day(s) unless you deny it.' + ), + grantee_detail=( + f'Requested emergency vault access from grantor_id={ea.grantor_id} ' + f'(wait: {ea.wait_days}d)' + ), ) db.session.commit() @@ -291,13 +330,25 @@ def get_emergency_vault(ea_id): 'error': f'Wait period not yet elapsed ({days_left:.1f} day(s) remaining)' }), 403 - AuditLog.log( - user_id=g.current_user_id, - action='emergency_access.vault_retrieved', - resource_type='emergency_access', - resource_id=ea.id, - detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}', - ip_address=client_ip(), + # Record the retrieval. Access is deliberately not revoked afterwards — the + # grantor may be unable to re-provision, and a failed import must not strand + # the grantee — but every retrieval is counted and shown to the grantor, who + # can revoke the grant outright. + now = datetime.now(timezone.utc).replace(tzinfo=None) + is_first = ea.vault_retrieved_at is None + if is_first: + ea.vault_retrieved_at = now + ea.vault_retrieval_count = (ea.vault_retrieval_count or 0) + 1 + + _log_for_both( + ea, + 'emergency_access.vault_retrieved', + grantor_detail=( + f'{ea.grantee_email} retrieved your emergency vault snapshot ' + f'({"first" if is_first else f"retrieval #{ea.vault_retrieval_count}"}). ' + 'Remove the grant if this was not expected.' + ), + grantee_detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}', ) db.session.commit() diff --git a/app/static/css/app.css b/app/static/css/app.css index 3b06349..4b7a710 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -953,6 +953,25 @@ ul { border: 1px solid #ffb74d; } +/* Emergency access: a pending request or a retrieved snapshot is the one thing + in this list the grantor must not scroll past, so it gets the strongest + treatment available rather than the amber used for ordinary warnings. */ +.badge-danger { + background: #ffebee; + color: #b71c1c; + border: 1px solid #ef9a9a; +} + +.share-item.em-alert { + border-left: 3px solid #c62828; + background: #fff5f5; +} + +.share-meta.em-retrieved { + color: #b71c1c; + font-weight: 500; +} + .badge-info { background: #e3f2fd; color: #1565c0; diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 73feb1a..457311c 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -2445,6 +2445,16 @@ const Vault = (() => { } } + /** + * Whole days left before a pending emergency request unlocks. Mirrors the + * server's wait_elapsed calculation in EmergencyAccess.wait_elapsed. + */ + function _emDaysRemaining(g) { + if (!g.request_initiated_at) return g.wait_days; + const elapsedMs = Date.now() - new Date(g.request_initiated_at).getTime(); + return Math.max(0, Math.ceil(g.wait_days - elapsedMs / 86400000)); + } + function renderEmergencyGrants(grants) { const ul = document.getElementById("em-grants-list"); if (!ul) return; @@ -2471,17 +2481,32 @@ const Vault = (() => { } } if (g.status === "pending") { + // The wait period is the only thing standing between a request and + // the grantee reading the vault, so make the countdown explicit + // rather than showing a bare status word. const waitInfo = g.wait_elapsed - ? "Wait period elapsed" - : "Access requested"; - actions = `${waitInfo} - `; + ? "⚠ Wait elapsed — access is available now" + : `⏳ Unlocks in ${_emDaysRemaining(g)} day(s)`; + actions = `${waitInfo} + `; } - return `