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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user