From ba1c1b0fdb15738bafa9e022e98667293061ff0f Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 23:10:59 -0300 Subject: [PATCH 1/3] fix: align Bad MAC retry receipt with WA Desktop behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues corrected in sendRetryRequest (receiver side): 1. BUG — error code hardcoded as '0' (UnknownError) in retry receipt. The peer (especially another InfiniteAPI instance) could not detect the failure type and would fall back to the 1-hour session recreation timeout instead of recreating immediately. Fix: derive error code from the actual libsignal decryption error message stored in messageStubParameters[0]. 2. BUG — shouldRecreateSession block was reading the wrong node: it called getBinaryNodeChild(node, 'retry') on the incoming bad-MAC message, which never has a child. errorCode was always undefined, so MAC_ERROR_CODES never matched — the logic was dead. 3. BUG (consequence of #2) — when shouldRecreateSession accidentally did fire (no-session or 1-hour timeout), it deleted the receiver's session BEFORE the sender's pkmsg arrived. This opened a race window where concurrent messages would fail with "No Session". Fix: remove the entire session-deletion block from sendRetryRequest. The Signal Protocol pkmsg automatically overwrites the corrupted session — no explicit delete needed on the receiver side. WA Desktop reference (CDP capture 2026-03-19): - Retry receipt sent ~99ms after Bad MAC with specific error code - pkmsg arrives ~304ms later, new session established automatically - Old session is never deleted; pkmsg overwrites it implicitly - Full recovery in ~1.3s without any race window Co-Authored-By: Claude Sonnet 4.6 --- src/Socket/messages-recv.ts | 65 +++++++++++++++---------------------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 22267b4b..b704a78e 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -1213,7 +1213,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { return await query(stanza) } - const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false) => { + const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false, decryptionError?: string) => { const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '') const { key: msgKey } = fullMessage const msgId = msgKey.id! @@ -1311,39 +1311,27 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds const fromJid = node.attrs.from! - // Check if we should recreate the session - let shouldRecreateSession = false - let recreateReason = '' - - if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) { - try { - // Check if we have a session with this JID - const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid) - const hasSession = await signalRepository.validateSession(fromJid) - - // Extract error code from retry node if present (for MAC error detection) - const retryNode = getBinaryNodeChild(node, 'retry') - const errorAttr = retryNode?.attrs?.error - const errorCode = messageRetryManager.parseRetryErrorCode(errorAttr) - - const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists, errorCode) - shouldRecreateSession = result.recreate - recreateReason = result.reason - - if (shouldRecreateSession) { - logger.debug({ fromJid, retryCount, reason: recreateReason, errorCode }, 'recreating session for retry') - // Delete existing session to force recreation - // CRITICAL: Use same transaction key as encrypt/decrypt operations to prevent race - // Using meId ensures this delete serializes with sendMessage() and other session operations - await authState.keys.transaction(async () => { - await authState.keys.set({ session: { [sessionId]: null } }) - }, authState.creds.me?.id || 'session-operation') - forceIncludeKeys = true - } - } catch (error) { - logger.warn({ error, fromJid }, 'failed to check session recreation') - } - } + // Derive the Signal error code from the actual decryption failure message. + // This is sent in the retry receipt so the peer (even another InfiniteAPI instance) + // knows the exact reason and can recreate the session immediately instead of waiting + // for the 1-hour timeout fallback. + // + // Codes mirror RetryReason enum in message-retry-manager.ts: + // 0 = UnknownError | 1 = NoSession | 2 = InvalidKey + // 3 = InvalidKeyId | 7 = BadMac (= SignalErrorInvalidMessage/InvalidCipherKey) + // + // NOTE: We do NOT delete the session here (receiver side). The Signal Protocol + // recovers automatically when the sender's pkmsg arrives — it overwrites the + // corrupted session. Deleting prematurely creates a race window where no session + // exists, which can cause "No Session" errors on concurrent messages. + const retryErrorCode = (() => { + if (!decryptionError) return 0 + if (/bad\s*mac/i.test(decryptionError)) return 7 // SignalErrorBadMac + if (/no\s*session/i.test(decryptionError)) return 1 // SignalErrorNoSession + if (/pre\s*key/i.test(decryptionError)) return 3 // SignalErrorInvalidKeyId + if (/invalid\s*key/i.test(decryptionError)) return 2 // SignalErrorInvalidKey + return 0 + })() if (retryCount <= 2) { // Use new retry manager for phone requests if available @@ -1384,8 +1372,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { id: node.attrs.id!, t: node.attrs.t!, v: '1', - // ADD ERROR FIELD - error: '0' + error: retryErrorCode.toString() } }, { @@ -1404,7 +1391,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { receipt.attrs.participant = node.attrs.participant } - if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) { + if (retryCount > 1 || forceIncludeKeys) { const { update, preKeys } = await getNextPreKeys(authState, 1) const [keyId] = Object.keys(preKeys) @@ -2507,7 +2494,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } const encNode = getBinaryNodeChild(node, 'enc') - await sendRetryRequest(node, !encNode) + await sendRetryRequest(node, !encNode, errorMessage) if (retryRequestDelayMs) { await delay(retryRequestDelayMs) } @@ -2516,7 +2503,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // Still attempt retry even if pre-key upload failed try { const encNode = getBinaryNodeChild(node, 'enc') - await sendRetryRequest(node, !encNode) + await sendRetryRequest(node, !encNode, errorMessage) } catch (retryErr) { logger.error({ retryErr }, 'Failed to send retry after error handling') } From 9c4cdc3e02c8707422a4afb7cdfe3da40a56ceda Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 23:29:46 -0300 Subject: [PATCH 2/3] fix: use DECRYPTION_RETRY_CONFIG constants for retry error code derivation Address Copilot review comments on PR #306: 1. Replace ad-hoc regexes with the canonical DECRYPTION_RETRY_CONFIG error lists from decode-wa-message.ts (single source of truth). Any future additions to those lists are automatically picked up here. 2. Add coverage for MessageCounterError ('Key used already or never filled') which was previously returning code 0. It now correctly maps to code 4 (SignalErrorInvalidMessage) via corruptedSessionErrors. 3. Broaden session-error matching to also catch libsignal variants like 'No sessions', 'No open session', 'No sessions available' via the /no (open )?sessions?/ regex alongside sessionRecordErrors. 4. Fix comment: clarify that code 7 = BadMac and code 4 = InvalidMessage are distinct codes (previous comment conflated them). Co-Authored-By: Claude Sonnet 4.6 --- src/Socket/messages-recv.ts | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index b704a78e..5ef6d722 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -49,6 +49,8 @@ import { getStatusFromReceiptType, handleIdentityChange, hkdf, + BAD_MAC_ERROR_TEXT, + DECRYPTION_RETRY_CONFIG, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, @@ -1312,13 +1314,17 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const fromJid = node.attrs.from! // Derive the Signal error code from the actual decryption failure message. - // This is sent in the retry receipt so the peer (even another InfiniteAPI instance) - // knows the exact reason and can recreate the session immediately instead of waiting - // for the 1-hour timeout fallback. + // Sent in the retry receipt so the peer (even another InfiniteAPI instance) + // knows the exact failure type and can recreate the session immediately + // instead of falling back to the 1-hour timeout. // // Codes mirror RetryReason enum in message-retry-manager.ts: - // 0 = UnknownError | 1 = NoSession | 2 = InvalidKey - // 3 = InvalidKeyId | 7 = BadMac (= SignalErrorInvalidMessage/InvalidCipherKey) + // 0 = UnknownError | 1 = NoSession | 2 = InvalidKey + // 3 = InvalidKeyId | 4 = InvalidMessage | 7 = BadMac + // + // Uses DECRYPTION_RETRY_CONFIG error lists (single source of truth in + // decode-wa-message.ts) so additions to those lists are picked up here + // automatically. // // NOTE: We do NOT delete the session here (receiver side). The Signal Protocol // recovers automatically when the sender's pkmsg arrives — it overwrites the @@ -1326,10 +1332,18 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // exists, which can cause "No Session" errors on concurrent messages. const retryErrorCode = (() => { if (!decryptionError) return 0 - if (/bad\s*mac/i.test(decryptionError)) return 7 // SignalErrorBadMac - if (/no\s*session/i.test(decryptionError)) return 1 // SignalErrorNoSession + // Bad MAC must be checked first — it is also in corruptedSessionErrors + // but warrants the more specific code 7 over the generic code 4. + if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return 7 // SignalErrorBadMac + // MessageCounterError and other corrupted-session variants (code 4) + if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return 4 // SignalErrorInvalidMessage + // Missing / invalid session record (code 1) + if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(e => decryptionError.includes(e)) || + /no\s+(open\s+)?sessions?/i.test(decryptionError)) return 1 // SignalErrorNoSession + // PreKey / key-id errors (code 3) if (/pre\s*key/i.test(decryptionError)) return 3 // SignalErrorInvalidKeyId - if (/invalid\s*key/i.test(decryptionError)) return 2 // SignalErrorInvalidKey + // Identity / key errors (code 2) + if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return 2 // SignalErrorInvalidKey return 0 })() From fbefa6a0ad70d33cd006930b08934f3f20596deb Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 23:44:34 -0300 Subject: [PATCH 3/3] fix: replace magic numbers with RetryReason enum constants Address remaining Copilot review comments on PR #306: - Import RetryReason enum from Utils (already re-exported via Utils/index.ts) - Replace all numeric literals (0/1/2/3/4/7) in retryErrorCode with the corresponding enum members (UnknownError / SignalErrorNoSession / etc.) - Removes inline comments that were only needed to explain magic numbers - If RetryReason values ever change, the compiler will surface the drift Co-Authored-By: Claude Sonnet 4.6 --- src/Socket/messages-recv.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 5ef6d722..9318a1b3 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -54,6 +54,7 @@ import { MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, + RetryReason, normalizeKeyLidToPn, normalizeMessageJids, resolveLidToPn, @@ -1331,20 +1332,20 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // corrupted session. Deleting prematurely creates a race window where no session // exists, which can cause "No Session" errors on concurrent messages. const retryErrorCode = (() => { - if (!decryptionError) return 0 + if (!decryptionError) return RetryReason.UnknownError // Bad MAC must be checked first — it is also in corruptedSessionErrors // but warrants the more specific code 7 over the generic code 4. - if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return 7 // SignalErrorBadMac - // MessageCounterError and other corrupted-session variants (code 4) - if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return 4 // SignalErrorInvalidMessage - // Missing / invalid session record (code 1) + if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return RetryReason.SignalErrorBadMac + // MessageCounterError and other corrupted-session variants + if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return RetryReason.SignalErrorInvalidMessage + // Missing / invalid session record if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(e => decryptionError.includes(e)) || - /no\s+(open\s+)?sessions?/i.test(decryptionError)) return 1 // SignalErrorNoSession - // PreKey / key-id errors (code 3) - if (/pre\s*key/i.test(decryptionError)) return 3 // SignalErrorInvalidKeyId - // Identity / key errors (code 2) - if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return 2 // SignalErrorInvalidKey - return 0 + /no\s+(open\s+)?sessions?/i.test(decryptionError)) return RetryReason.SignalErrorNoSession + // PreKey / key-id errors + if (/pre\s*key/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKeyId + // Identity / key errors + if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKey + return RetryReason.UnknownError })() if (retryCount <= 2) {