release(v4.2.1): fix concurrent-ratchet desync via OutboundQueue waiter cursor
Pull-mode httpClient + drainer + parallel RPCs against the same peer deteriorated after ~10s with `DecryptionError`. Two bugs combined: - `OutboundQueue.enqueue` woke `drain` waiters with a `since=0` snapshot, replaying already-processed events into `Shade.acceptTransferEnvelope` → `manager.decrypt` twice. The duplicate consumed an already-used skipped key and corrupted the Double Ratchet receive chain. - `ratchetDecrypt` then propagated the corruption: a same-DH message behind the chain with no cached skipped key fell through to `kdfChainKey` on the ahead state and rewound `chain.counter`, permanently desyncing the chain. Fix `OutboundQueue` to honor each waiter's `since`, and harden `ratchetDecrypt` so any future duplicate fails cleanly without mutating state. Adds regression coverage at all three layers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -185,6 +185,22 @@ export async function ratchetDecrypt(
|
||||
if (!session.receiveChain) {
|
||||
throw new DecryptionError('No receiving chain available');
|
||||
}
|
||||
// Defense-in-depth: a same-DH message whose counter is already
|
||||
// behind the chain — and that did NOT match a cached skipped key —
|
||||
// is either a duplicate we already decrypted (skipped key was
|
||||
// consumed) or one whose key was evicted under cache pressure.
|
||||
// Falling through would call kdfChainKey on the *current* (ahead)
|
||||
// chainKey and then rewind `chain.counter = message.counter + 1`,
|
||||
// permanently desyncing the chain so every subsequent decrypt
|
||||
// returns wrong-key. Reject without mutating state instead.
|
||||
if (
|
||||
!isNewRatchet &&
|
||||
message.counter < session.receiveChain.counter
|
||||
) {
|
||||
throw new DecryptionError(
|
||||
'Failed to decrypt message — wrong key or tampered data',
|
||||
);
|
||||
}
|
||||
await skipMessageKeys(crypto, session, message.dhPublicKey, session.receiveChain, message.counter);
|
||||
|
||||
// Advance the receiving chain one more step to get this message's key
|
||||
|
||||
Reference in New Issue
Block a user