fix(messages): address S2 review — partner key refresh + reactive me

- On a decrypt failure, refresh the partner's cached public key (and drop the
  stale derived conversation key) once per pass and retry. A passphrase reset
  rotates a keypair, so a partner who cached the old public key would otherwise
  encrypt undecryptable messages until a reload; the first failed decrypt now
  self-heals reads AND future sends. (pubCache was assumed set-once.)
- KeyGate reads has_password via a reactive useQuery(['me'], enabled:false)
  passive observer instead of a one-shot getQueryData, so the reset password
  field stays correct even if me resolves after mount.
- Document the remaining multi-device staleness (a reset on another device
  leaves this one 'ready' with the dead key) as a known limitation in useKeyState.
This commit is contained in:
2026-07-23 03:11:35 +02:00
parent 1119eb0218
commit 26e6562921
4 changed files with 53 additions and 11 deletions
+21 -7
View File
@@ -8,7 +8,7 @@ import { useScrollFade } from "../lib/useScrollFade";
import { LoadingState, StateMessage } from "./QueryState";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
@@ -49,14 +49,28 @@ export default function ChatThread({
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
let pub = await partnerPub(partnerId);
let refreshed = false;
for (const m of q.data?.items ?? []) {
if (m.ciphertext && m.iv) {
try {
const pub = await partnerPub(partnerId);
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
} catch {
out[m.id] = "⚠ " + t("messages.cantDecrypt");
if (!m.ciphertext || !m.iv) continue;
try {
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
} catch {
// A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so
// this device's cached public key is stale. Refresh it ONCE per pass and retry — that
// also re-derives future sends under the current key. A genuinely bad message still ends
// up "can't decrypt".
if (!refreshed) {
refreshed = true;
try {
pub = await refreshPartnerPub(partnerId);
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
continue;
} catch {
/* fall through to the error label below */
}
}
out[m.id] = "⚠ " + t("messages.cantDecrypt");
}
}
if (!cancelled) setPlain(out);
+4 -2
View File
@@ -22,8 +22,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
});
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;
// have no password to check. Reactive passive observer of the App-owned ["me"] query (enabled:
// false — App does the fetching), so hasPassword stays correct even if me resolves after mount.
const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const hasPassword = meQ.data?.has_password ?? false;
const [view, setView] = useState<"gate" | "reset">("gate");
const [pass, setPass] = useState("");