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 { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { relativeTime } from "../lib/format";
|
import { relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
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 * as e2ee from "../lib/e2ee";
|
||||||
import KeyGate from "./KeyGate";
|
import KeyGate from "./KeyGate";
|
||||||
|
|
||||||
@@ -49,14 +49,28 @@ export default function ChatThread({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const out: Record<number, string> = {};
|
const out: Record<number, string> = {};
|
||||||
|
let pub = await partnerPub(partnerId);
|
||||||
|
let refreshed = false;
|
||||||
for (const m of q.data?.items ?? []) {
|
for (const m of q.data?.items ?? []) {
|
||||||
if (m.ciphertext && m.iv) {
|
if (!m.ciphertext || !m.iv) continue;
|
||||||
try {
|
try {
|
||||||
const pub = await partnerPub(partnerId);
|
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
||||||
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
} catch {
|
||||||
} catch {
|
// A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so
|
||||||
out[m.id] = "⚠ " + t("messages.cantDecrypt");
|
// 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);
|
if (!cancelled) setPlain(out);
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
|||||||
});
|
});
|
||||||
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
|
// 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.
|
// have no password to check. Reactive passive observer of the App-owned ["me"] query (enabled:
|
||||||
const hasPassword = qc.getQueryData<{ has_password?: boolean }>(["me"])?.has_password ?? false;
|
// 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 [view, setView] = useState<"gate" | "reset">("gate");
|
||||||
const [pass, setPass] = useState("");
|
const [pass, setPass] = useState("");
|
||||||
|
|||||||
@@ -185,6 +185,13 @@ export async function unlock(
|
|||||||
await installKey(userId, await importPrivate(pkcs8));
|
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 ---
|
// --- per-conversation encryption ---
|
||||||
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
|
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
|
||||||
const cached = convKeys.get(partnerId);
|
const cached = convKeys.get(partnerId);
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "./api";
|
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
|
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
|
||||||
|
|
||||||
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
|
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
|
||||||
export const POLL_MS = 20000;
|
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>();
|
const pubCache = new Map<number, string>();
|
||||||
export async function partnerPub(partnerId: number): Promise<string> {
|
export async function partnerPub(partnerId: number): Promise<string> {
|
||||||
const cached = pubCache.get(partnerId);
|
const cached = pubCache.get(partnerId);
|
||||||
@@ -19,6 +21,15 @@ export async function partnerPub(partnerId: number): Promise<string> {
|
|||||||
return r.public_key;
|
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.
|
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
|
||||||
function useUnlocked(): boolean {
|
function useUnlocked(): boolean {
|
||||||
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
|
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
|
// 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
|
// 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".
|
// 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 type KeyState = "loading" | "setup" | "unlock" | "ready";
|
||||||
export function useKeyState(): KeyState {
|
export function useKeyState(): KeyState {
|
||||||
const unlocked = useUnlocked();
|
const unlocked = useUnlocked();
|
||||||
|
|||||||
Reference in New Issue
Block a user