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)
+89 -5
View File
@@ -1,13 +1,17 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, ShieldCheck } from "lucide-react";
import { api } from "../lib/api";
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
// (the Messages page, a dock window) swaps out of the gate automatically.
// (the Messages page, a dock window) swaps out of the gate automatically. In unlock mode it also
// offers a forgotten-passphrase reset (the only escape from the set-once wall).
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -17,9 +21,14 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
staleTime: 60_000,
});
const configured = keyQ.data?.configured ?? false;
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
// have no password to check. Read from the App-owned cache — KeyGate is always rendered signed in.
const hasPassword = qc.getQueryData<{ has_password?: boolean }>(["me"])?.has_password ?? false;
const [view, setView] = useState<"gate" | "reset">("gate");
const [pass, setPass] = useState("");
const [confirm, setConfirm] = useState("");
const [curPass, setCurPass] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -43,9 +52,79 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
}
}
function toReset() {
setErr(null);
setPass("");
setConfirm("");
setView("reset");
}
function cancelReset() {
setErr(null);
setCurPass("");
setView("gate");
}
async function doReset() {
setErr(null);
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
setBusy(true);
try {
await api.resetMessageKey(hasPassword ? curPass : undefined);
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
// in setup mode. Invalidate last so the refetch sees configured:false.
await e2ee.resetLocal(meId);
setCurPass("");
setView("gate");
qc.invalidateQueries({ queryKey: ["message-key"] });
} catch (e) {
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
} finally {
setBusy(false);
}
}
if (keyQ.isLoading)
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
if (view === "reset")
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0" />
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
</div>
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
{hasPassword && (
<input
type="password"
autoComplete="current-password"
value={curPass}
onChange={(e) => setCurPass(e.target.value)}
placeholder={t("messages.resetCurrentPass")}
className={inputCls}
/>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
<div className="flex gap-2">
<button
onClick={cancelReset}
disabled={busy}
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
>
{t("common.cancel")}
</button>
<button
onClick={doReset}
disabled={busy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-red-500/90 text-white hover:bg-red-500 transition disabled:opacity-40"
>
<AlertTriangle className="w-4 h-4" />
{t("messages.resetButton")}
</button>
</div>
</div>
);
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
@@ -65,7 +144,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
onChange={(e) => setPass(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
placeholder={t("messages.passphrase")}
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
className={inputCls}
/>
{!configured && (
<input
@@ -73,7 +152,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("messages.passphraseConfirm")}
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
className={inputCls}
/>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
@@ -85,6 +164,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</button>
{configured && (
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
{t("messages.forgotPass")}
</button>
)}
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
);
+8 -1
View File
@@ -31,5 +31,12 @@
"passTooShort": "Use at least 6 characters.",
"passMismatch": "The passphrases don't match.",
"wrongPass": "Wrong passphrase.",
"setupFailed": "Couldn't set up secure messaging. Please try again."
"setupFailed": "Couldn't set up secure messaging. Please try again.",
"forgotPass": "Forgot your passphrase?",
"resetTitle": "Reset secure messaging",
"resetWarn": "This permanently erases your entire message history — every conversation, for you and the people you messaged — because it can no longer be decrypted. Then you can set a new passphrase and start fresh. This can't be undone.",
"resetCurrentPass": "Current account password",
"resetNeedPass": "Enter your current account password to confirm.",
"resetButton": "Reset & erase history",
"resetFailed": "Couldn't reset secure messaging. Please try again."
}
+8 -1
View File
@@ -31,5 +31,12 @@
"passTooShort": "Legalább 6 karakter legyen.",
"passMismatch": "A jelszavak nem egyeznek.",
"wrongPass": "Hibás jelszó.",
"setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra."
"setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra.",
"forgotPass": "Elfelejtetted a jelszavad?",
"resetTitle": "Biztonságos üzenetküldés visszaállítása",
"resetWarn": "Ez véglegesen törli a teljes üzenet-előzményedet — minden beszélgetést, nálad és a partnereidnél is —, mert az többé nem visszafejthető. Utána új jelszót állíthatsz be és tiszta lappal indulhatsz. Ez nem visszavonható.",
"resetCurrentPass": "Jelenlegi fiók-jelszó",
"resetNeedPass": "Add meg a jelenlegi fiók-jelszavad a megerősítéshez.",
"resetButton": "Visszaállítás és előzmény törlése",
"resetFailed": "A biztonságos üzenetküldés visszaállítása nem sikerült. Próbáld újra."
}
+8
View File
@@ -1623,6 +1623,14 @@ export const api = {
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
// Forgotten-passphrase reset: drops the server key (and the now-undecryptable history) so a new
// passphrase can be set. Password accounts must pass their current password; errors shown inline.
resetMessageKey: (currentPassword?: string): Promise<{ configured: boolean }> =>
req(
"/api/messages/keys/reset",
{ method: "POST", body: JSON.stringify({ current_password: currentPassword ?? null }) },
{ quiet: true },
),
messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
req(`/api/messages/keys/${userId}`),
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
+10
View File
@@ -85,6 +85,16 @@ export async function clearDevice(userId: number): Promise<void> {
});
}
// Wipe this device's E2EE identity: forget the unlocked key + every derived conversation key,
// delete the persisted copy, and notify observers so the gate re-appears. Used by the passphrase
// reset, which also drops the server key so the next screen is a fresh setup.
export async function resetLocal(userId: number): Promise<void> {
privKey = null;
convKeys.clear();
await clearDevice(userId);
emitUnlock();
}
// --- key derivation ---
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [