Compare commits

...

4 Commits

Author SHA1 Message Date
byGalax 615770722e chore(desktop): release v0.21.3 2026-05-21 22:52:50 +02:00
byGalax f1cba99b9e fix(conv-key): bootstrap re-fetches canonical key after share to handle concurrent race 2026-05-21 22:51:17 +02:00
byGalax f60c5c676a chore(desktop): release v0.21.2 2026-05-18 15:40:56 +02:00
byGalax 8e6be3256d fix(conv-key): rotate on unwrap failure (post-reset_user_key recovery) 2026-05-18 15:38:31 +02:00
2 changed files with 65 additions and 17 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.21.1",
"version": "0.21.3",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
+64 -16
View File
@@ -110,7 +110,26 @@ export async function bootstrapConvKey(
p_bundles: bundles,
});
if (error) throw error;
const handle = { conversationId, keyVersion, key: convKey };
// `share_conv_keys` uses `ON CONFLICT (conv, recipient_user_id, key_version)
// DO NOTHING`. If a concurrent peer bootstrapped first at the same version,
// OUR INSERTs were silently skipped server-side and the row on the server
// holds THEIR conv-key, not ours. Trusting the locally-generated key here
// would leave both clients with mutually un-decryptable bundles (each
// encrypting/decrypting with its own key — exactly the bug that broke
// conv aae12d84). Re-fetch our own bundle and unwrap to get the CANONICAL
// server key. Whoever wrote first wins; the loser converges.
const ownBundle = await fetchKeyBundle(client, conversationId, own.userId, keyVersion);
if (!ownBundle) {
throw new Error('bootstrapConvKey: own bundle missing after share_conv_keys');
}
const canonicalKey = await unwrapConvKey(
ownBundle.encryptedKey,
ownBundle.nonce,
ownBundle.sender.senderPublicKey,
own.privateKey,
);
const handle = { conversationId, keyVersion, key: canonicalKey };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;
}
@@ -125,12 +144,25 @@ export async function getOrCreateConvKey(
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
if (bundle) {
const key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
);
const handle = { conversationId, keyVersion: version, key };
cache.set(cacheKey(conversationId, version), handle);
return handle;
try {
const key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
);
const handle = { conversationId, keyVersion: version, key };
cache.set(cacheKey(conversationId, version), handle);
return handle;
} catch (err) {
// A bundle exists for us but our current private key cannot unwrap it.
// The most common cause is `reset_user_key`: a fresh user-key pair was
// generated locally while the on-server bundle is still wrapped against
// the previous public key. Treat this the same as "no bundle for me" —
// mint a fresh conv-key at version+1 wrapped to our CURRENT key. Old
// messages stay unreadable for us; new ones flow.
console.warn(
'[conv-key] unwrap own bundle failed at v' + version + ' — auto-rotating',
err,
);
}
}
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
.select('recipient_user_id', { count: 'exact', head: true })
@@ -138,12 +170,13 @@ export async function getOrCreateConvKey(
.eq('key_version', version);
if (cntErr) throw cntErr;
if ((count ?? 0) > 0) {
// Rows exist for this version, but none for me. Either I lost the device-key
// that originally received my bundle, or my own bundle was wiped by the
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
// unreadable for me; new ones flow.
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
// Rows exist for this version, but none usable for me. Either I lost the
// device-key that originally received my bundle, my own bundle was wiped
// by the 0.18.0 reset_user_key bug, or my key was reset and the existing
// bundle is unwrappable (handled in the try/catch above). The only way
// out is to mint a fresh conv-key at version+1 and wrap it for everyone
// we can. Old messages stay unreadable for me; new ones flow.
console.info('[conv-key] no usable bundle for me at v' + version + ' — auto-rotating');
return rotateConvKey(client, conversationId, own);
}
return bootstrapConvKey(client, conversationId, own, version);
@@ -248,9 +281,24 @@ export async function tryGetConvKey(
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
if (!bundle) return null;
const key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
);
let key: Uint8Array;
try {
key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
);
} catch (err) {
// Bundle exists but the current private key doesn't unwrap it (typically
// after `reset_user_key`). Return null so the caller treats the message
// as un-decryptable instead of throwing and killing the whole batch.
// The conversation will be auto-rotated to a fresh key on the next send
// or chat open via `getOrCreateConvKey`'s own recovery path.
console.warn(
'[conv-key] tryGetConvKey unwrap failed at v' + keyVersion +
' (conv=' + conversationId.slice(0, 8) + ') — marking as un-decryptable',
err,
);
return null;
}
const handle = { conversationId, keyVersion, key };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;