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:
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -185,6 +185,13 @@ export async function unlock(
|
||||
await installKey(userId, await importPrivate(pkcs8));
|
||||
}
|
||||
|
||||
// Drop the cached derived conversation key for a partner. convKeyFor caches by partnerId alone, so
|
||||
// after a partner rotates their public key (a passphrase reset) the caller must call this before
|
||||
// re-deriving with the fresh key — otherwise the stale key would be returned.
|
||||
export function forgetConv(partnerId: number): void {
|
||||
convKeys.delete(partnerId);
|
||||
}
|
||||
|
||||
// --- per-conversation encryption ---
|
||||
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
|
||||
const cached = convKeys.get(partnerId);
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import { isUnlocked, subscribeUnlock } from "./e2ee";
|
||||
import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee";
|
||||
|
||||
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
|
||||
|
||||
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
|
||||
export const POLL_MS = 20000;
|
||||
|
||||
// Set-once cache of partner public keys (the server is the directory; keys never change).
|
||||
// Cache of partner public keys (the server is the directory). Keys are stable in normal use, but a
|
||||
// partner can rotate theirs via a passphrase reset — refreshPartnerPub() evicts a stale entry (and
|
||||
// its derived conversation key) so the next fetch picks up the new key.
|
||||
const pubCache = new Map<number, string>();
|
||||
export async function partnerPub(partnerId: number): Promise<string> {
|
||||
const cached = pubCache.get(partnerId);
|
||||
@@ -19,6 +21,15 @@ export async function partnerPub(partnerId: number): Promise<string> {
|
||||
return r.public_key;
|
||||
}
|
||||
|
||||
// Drop a partner's cached public key + derived conversation key and refetch. Called after a decrypt
|
||||
// fails, which can mean the partner reset their passphrase and published a NEW keypair; refetching
|
||||
// lets this device both read their new messages and encrypt future ones under the current key.
|
||||
export async function refreshPartnerPub(partnerId: number): Promise<string> {
|
||||
pubCache.delete(partnerId);
|
||||
forgetConv(partnerId);
|
||||
return partnerPub(partnerId);
|
||||
}
|
||||
|
||||
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
|
||||
function useUnlocked(): boolean {
|
||||
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
|
||||
@@ -28,6 +39,14 @@ function useUnlocked(): boolean {
|
||||
// local unlock state gives the real gate: "setup" if the server has no key (even if a stale
|
||||
// private key lingers in this browser — setup overwrites it), "unlock" if the server has a key
|
||||
// but this device hasn't unlocked it, else "ready".
|
||||
//
|
||||
// KNOWN LIMITATION (multi-device, passphrase reset): this doesn't compare the locally-unlocked
|
||||
// key's public half against the server's current public_key, so if the user resets their passphrase
|
||||
// on ANOTHER device (new keypair), this already-unlocked device stays "ready" with the now-dead old
|
||||
// key and silently fails to decrypt new messages until it's manually re-unlocked. Accepted for now:
|
||||
// it fits the E2EE scope (static pairwise key, no forward secrecy) and self-heals once the device is
|
||||
// re-unlocked with the new passphrase. Incoming-message decrypt failures on the CURRENT device DO
|
||||
// self-heal via refreshPartnerPub() (a partner's rotation), which covers the common single-device case.
|
||||
export type KeyState = "loading" | "setup" | "unlock" | "ready";
|
||||
export function useKeyState(): KeyState {
|
||||
const unlocked = useUnlocked();
|
||||
|
||||
Reference in New Issue
Block a user