feat(messages): R4 S2 — forgotten-passphrase reset for E2EE

The set-once key guard made a lost passphrase a permanent wall (409 on re-setup,
KeyGate offered only unlock/setup). Add an escape hatch:

- POST /api/messages/keys/reset drops the user's MessageKey so a new passphrase
  can be set. Because every conversation key is derived from the keypair,
  replacing it orphans all stored ciphertext in both directions, so the now-dead
  `user` messages are deleted too (clean slate; system messages untouched).
  Password accounts must re-verify (a hijacked session must not silently reset
  messaging and read future messages); Google-only accounts rely on the session
  + the client danger-confirm. Rate-limited per user.
- KeyGate gains a "Forgot passphrase?" path in unlock mode → a danger reset view
  (spells out the permanent history loss; a current-password field for password
  accounts) → resets and returns to a fresh setup.
- e2ee.resetLocal wipes this device's key + derived conversation keys + the
  IndexedDB copy; api.resetMessageKey; HU/EN strings.
This commit is contained in:
2026-07-23 02:57:27 +02:00
parent 1af679bfc5
commit 1119eb0218
6 changed files with 169 additions and 8 deletions
+46 -1
View File
@@ -17,7 +17,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy import and_, case, delete, func, or_, select
from sqlalchemy.orm import Session
from app.auth import require_human, resolved_user_id
@@ -25,6 +25,7 @@ from app.db import SessionLocal, get_db
from app.models import Message, MessageKey, User
from app.ratelimit import RateLimiter
from app.realtime import manager
from app.security import verify_password
router = APIRouter(prefix="/api/messages", tags=["messages"])
@@ -32,6 +33,7 @@ MAX_CIPHERTEXT = 8000 # base64 of a generous plaintext + AES-GCM overhead
SYSTEM_PARTNER_ID = 0 # synthetic conversation partner for system messages
SYSTEM_NAME = "Siftlode"
_send_limiter = RateLimiter(max_events=30, window_seconds=60)
_reset_key_limiter = RateLimiter(max_events=5, window_seconds=300)
# The welcome is rendered server-side at creation time, so it carries its own translations
# (one per supported UI language). Kept short and non-private (every new user gets the same text).
@@ -62,6 +64,11 @@ class KeySetupIn(BaseModel):
key_check: str
class KeyResetIn(BaseModel):
# Present only for password accounts; a Google-only account has no password to re-verify.
current_password: str | None = None
class SendIn(BaseModel):
recipient_id: int
ciphertext: str
@@ -172,6 +179,44 @@ def setup_keys(
return {"configured": True}
@router.post("/keys/reset")
def reset_keys(
payload: KeyResetIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Forgotten-passphrase escape hatch: drop this user's secure-messaging key so they can set a
NEW passphrase (the set-once guard in setup_keys otherwise makes a lost passphrase a permanent
wall). Destroys the user's entire E2EE history BY DESIGN — every conversation key is derived
from this keypair, so replacing it orphans every stored ciphertext in both directions. Those
now-undecryptable `user` messages are deleted here (a clean slate for both parties) rather than
left as permanent "can't decrypt" clutter; `system` messages (plaintext) are untouched.
An account WITH a password must re-authenticate: a hijacked session must not be able to silently
reset messaging and then read the victim's future messages under a new passphrase. A password-
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
client-side danger confirmation."""
if not _reset_key_limiter.allow(str(user.id)):
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
if user.password_hash and not verify_password(
payload.current_password or "", user.password_hash
):
raise HTTPException(status_code=403, detail="Current password is incorrect.")
# Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
db.execute(
delete(Message).where(
Message.kind == "user",
or_(Message.sender_id == user.id, Message.recipient_id == user.id),
)
)
key = db.get(MessageKey, user.id)
if key is not None:
db.delete(key)
db.commit()
return {"configured": False}
@router.get("/keys/{user_id}")
def public_key(
user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)