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);