fix(messages): re-review — degrade gracefully when partner has no key
The decrypt-pass refactor hoisted partnerPub() above the loop and outside the try, so a keyless partner (one who reset and hasn't re-set-up) or a fetch error rejected the whole pass unhandled (setPlain never ran). Fetch the key lazily inside the try and once, so an empty thread never fetches and a fetch failure degrades to "can't decrypt" as before. Document the send-path staleness (a first proactive send after a partner's key rotation uses the stale cached key) as a known limitation alongside refreshPartnerPub.
This commit is contained in:
@@ -49,11 +49,15 @@ 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);
|
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
|
||||||
|
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
|
||||||
|
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
|
||||||
|
let pub: string | null = null;
|
||||||
let refreshed = false;
|
let refreshed = false;
|
||||||
for (const m of q.data?.items ?? []) {
|
for (const m of q.data?.items ?? []) {
|
||||||
if (!m.ciphertext || !m.iv) continue;
|
if (!m.ciphertext || !m.iv) continue;
|
||||||
try {
|
try {
|
||||||
|
if (!pub) 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
|
// A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export async function partnerPub(partnerId: number): Promise<string> {
|
|||||||
// Drop a partner's cached public key + derived conversation key and refetch. Called after a decrypt
|
// 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
|
// 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.
|
// lets this device both read their new messages and encrypt future ones under the current key.
|
||||||
|
//
|
||||||
|
// KNOWN LIMITATION: this refresh is wired only to the DECRYPT path. If you send a partner a message
|
||||||
|
// AFTER they rotated their key but BEFORE any of their messages arrived to trigger a refresh, that
|
||||||
|
// one send is encrypted under the stale cached key and is undecryptable for them (subsequent sends
|
||||||
|
// self-heal once an incoming message refreshes the cache). Accepted for now — same cross-user
|
||||||
|
// staleness envelope as the multi-device note in useKeyState; a fuller fix needs a key TTL / a
|
||||||
|
// send-time freshness check, which is out of the current no-forward-secrecy scope.
|
||||||
export async function refreshPartnerPub(partnerId: number): Promise<string> {
|
export async function refreshPartnerPub(partnerId: number): Promise<string> {
|
||||||
pubCache.delete(partnerId);
|
pubCache.delete(partnerId);
|
||||||
forgetConv(partnerId);
|
forgetConv(partnerId);
|
||||||
|
|||||||
Reference in New Issue
Block a user