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:
@@ -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