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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user