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 fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel 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 sqlalchemy.orm import Session
from app.auth import require_human, resolved_user_id 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.models import Message, MessageKey, User
from app.ratelimit import RateLimiter from app.ratelimit import RateLimiter
from app.realtime import manager from app.realtime import manager
from app.security import verify_password
router = APIRouter(prefix="/api/messages", tags=["messages"]) 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_PARTNER_ID = 0 # synthetic conversation partner for system messages
SYSTEM_NAME = "Siftlode" SYSTEM_NAME = "Siftlode"
_send_limiter = RateLimiter(max_events=30, window_seconds=60) _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 # 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). # (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 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): class SendIn(BaseModel):
recipient_id: int recipient_id: int
ciphertext: str ciphertext: str
@@ -172,6 +179,44 @@ def setup_keys(
return {"configured": True} 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}") @router.get("/keys/{user_id}")
def public_key( def public_key(
user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) 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 { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, ShieldCheck } from "lucide-react"; import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
import { api } from "../lib/api"; import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee"; 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. // 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 // 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 }) { export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -17,9 +21,14 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
staleTime: 60_000, staleTime: 60_000,
}); });
const configured = keyQ.data?.configured ?? false; 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 [pass, setPass] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
const [curPass, setCurPass] = useState("");
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false); 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) if (keyQ.isLoading)
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>; 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 ( return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}> <div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2"> <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)} onChange={(e) => setPass(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && configured && submit()} onKeyDown={(e) => e.key === "Enter" && configured && submit()}
placeholder={t("messages.passphrase")} 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 && ( {!configured && (
<input <input
@@ -73,7 +152,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
placeholder={t("messages.passphraseConfirm")} 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>} {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" /> <Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")} {configured ? t("messages.unlock") : t("messages.setUp")}
</button> </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>} {!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div> </div>
); );
+8 -1
View File
@@ -31,5 +31,12 @@
"passTooShort": "Use at least 6 characters.", "passTooShort": "Use at least 6 characters.",
"passMismatch": "The passphrases don't match.", "passMismatch": "The passphrases don't match.",
"wrongPass": "Wrong passphrase.", "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.", "passTooShort": "Legalább 6 karakter legyen.",
"passMismatch": "A jelszavak nem egyeznek.", "passMismatch": "A jelszavak nem egyeznek.",
"wrongPass": "Hibás jelszó.", "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"), messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> => setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }), 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 }> => messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
req(`/api/messages/keys/${userId}`), req(`/api/messages/keys/${userId}`),
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"), 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 --- // --- key derivation ---
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> { async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [ const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [