From 74fc289cd8c35b5c3e0ca7bf0961b1b345e215f0 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Wed, 4 Mar 2026 00:23:23 -0300 Subject: [PATCH] fix(signal): align Signal Protocol handling with WABA Android behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 corrections based on Frida reverse-engineering of WhatsApp Business Android: 1. resolveCanonicalJid: PN→LID resolution for transaction lock keys (prevent race conditions) 2. Retain PN session during LID migration (avoid No Session errors) 3. Delayed PreKey deletion with 5-min grace period (prevent Invalid PreKey ID races) 4. Surgical session cleanup: only delete the specific corrupted device, not all devices 5. Identity dual storage: save identity key in both LID and PN addresses 6. MAC error cooldown reduced 10s→1s (faster recovery, aligned with WABA) 7. Allow session recreation on first retry (retryCount >= 1 instead of > 1) Co-Authored-By: Claude Opus 4.6 --- src/Signal/libsignal.ts | 88 +++++++++++++++++++++++++----- src/Socket/messages-recv.ts | 4 +- src/Utils/decode-wa-message.ts | 49 ++--------------- src/Utils/message-retry-manager.ts | 2 +- 4 files changed, 84 insertions(+), 59 deletions(-) diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index 0f4a516d..5436dbea 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -302,6 +302,25 @@ export function makeLibSignalRepository( // Promise instead of each spawning their own DB transactions. const migrationInFlight = new Map>() + // Resolve PN JID to its canonical LID JID for transaction locking. + // This prevents PN/LID race conditions where concurrent operations for the + // same logical contact acquire different mutex locks because one uses PN + // and the other uses LID. (Aligned with WABA behavior — all operations use LID internally.) + const resolveCanonicalJid = async(jid: string): Promise => { + if (isAnyLidUser(jid)) { + return jid + } + + if (isAnyPnUser(jid)) { + const lid = await lidMapping.getLIDForPN(jid) + if (lid) { + return lid + } + } + + return jid + } + const repository: SignalRepositoryWithLIDStore = { decryptGroupMessage({ group, authorJid, msg }) { const senderName = jidToSignalSenderKeyName(group, authorJid) @@ -396,23 +415,24 @@ export function makeLibSignalRepository( return result } - // If it's not a sync message, we need to ensure atomicity - // For regular messages, we use a transaction to ensure atomicity + // Use canonical JID (PN→LID resolved) as transaction key to prevent + // PN/LID race conditions on the same logical session. + const canonicalJid = await resolveCanonicalJid(jid) return parsedKeys.transaction(async () => { return await doDecrypt() - }, jid) + }, canonicalJid) }, async encryptMessage({ jid, data }) { const addr = jidToSignalProtocolAddress(jid) const cipher = new libsignal.SessionCipher(storage, addr) - // Use transaction to ensure atomicity + const canonicalJid = await resolveCanonicalJid(jid) return parsedKeys.transaction(async () => { const { type: sigType, body } = await cipher.encrypt(data) const type = sigType === 3 ? 'pkmsg' : 'msg' return { type, ciphertext: Buffer.from(body, 'binary') } - }, jid) + }, canonicalJid) }, async encryptGroupMessage({ group, meId, data }) { @@ -654,9 +674,10 @@ export function makeLibSignalRepository( // Session exists (guaranteed from device discovery) const fromSession = libsignal.SessionRecord.deserialize(pnSession) if (fromSession.haveOpenSession()) { - // Queue for bulk update: copy to LID, delete from PN + // Queue for bulk update: copy to LID, retain PN session. + // WABA retains both PN and LID sessions during migration to avoid + // No Session errors if messages arrive via PN before migration completes. sessionUpdates[lidAddrStr] = fromSession.serialize() - sessionUpdates[pnAddrStr] = null migratedCount++ } @@ -776,6 +797,13 @@ function signalStorage( return id } + // Delayed PreKey deletion: grace period to handle race conditions + // where two pkmsg with the same preKeyId arrive nearly simultaneously. + // WABA deletes immediately (33ms), but we add a 5-min grace period + // because we can't handle "Invalid PreKey ID" errors at the native level. + const PREKEY_GRACE_PERIOD_MS = 5 * 60 * 1000 // 5 minutes + const pendingPreKeyDeletions = new Map>() + return { loadSession: async (id: string) => { try { @@ -808,7 +836,22 @@ function signalStorage( } } }, - removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }), + removePreKey: (id: number) => { + const keyId = id.toString() + // Clear any existing timer for this key + const existing = pendingPreKeyDeletions.get(keyId) + if (existing) { + clearTimeout(existing) + } + + // Schedule deletion after grace period + const timer = setTimeout(async () => { + pendingPreKeyDeletions.delete(keyId) + await keys.set({ 'pre-key': { [id]: null } }) + }, PREKEY_GRACE_PERIOD_MS) + + pendingPreKeyDeletions.set(keyId, timer) + }, loadSignedPreKey: () => { const key = creds.signedPreKey return { @@ -898,14 +941,24 @@ function signalStorage( // IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices const previousFingerprint = generateKeyFingerprint(existingKey) - // Delete old session and save new identity key atomically + // Delete old session and save new identity key atomically. + // Store identity in BOTH LID and PN addresses (WABA stores in both + // recipient_account_type=0 and type=1 with CONFLICT_REPLACE). + const identityUpdates: Record = { [wireJid]: identityKey } + if (wireJid !== id) { + identityUpdates[id] = identityKey + } + await keys.set({ session: { [wireJid]: null }, - 'identity-key': { [wireJid]: identityKey } + 'identity-key': identityUpdates }) - // Update cache + // Update cache for both addresses identityKeyCache.set(wireJid, identityKey) + if (wireJid !== id) { + identityKeyCache.set(id, identityKey) + } // Record metrics metrics.signalIdentityChanges?.inc({ type: 'changed' }) @@ -941,10 +994,19 @@ function signalStorage( if (!existingKey) { // NEW CONTACT - Trust On First Use (TOFU) - await keys.set({ 'identity-key': { [wireJid]: identityKey } }) + // Store in both LID and PN addresses (aligned with WABA dual identity storage) + const identityUpdates: Record = { [wireJid]: identityKey } + if (wireJid !== id) { + identityUpdates[id] = identityKey + } - // Update cache + await keys.set({ 'identity-key': identityUpdates }) + + // Update cache for both addresses identityKeyCache.set(wireJid, identityKey) + if (wireJid !== id) { + identityKeyCache.set(id, identityKey) + } // Record metrics metrics.signalIdentityChanges?.inc({ type: 'new' }) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 32c7dc04..1a08ea88 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -1314,7 +1314,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { let shouldRecreateSession = false let recreateReason = '' - if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) { + if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) { try { // Check if we have a session with this JID const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid) @@ -2033,7 +2033,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { let shouldRecreateSession = false let recreateReason = '' - if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) { + if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) { try { const sessionId = signalRepository.jidToSignalProtocolAddress(participant) diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index 4da274b4..f76c2855 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -488,8 +488,9 @@ export function isCorruptedSessionError(error: any): boolean { } /** - * Clean up corrupted session by deleting all device sessions for a JID. - * Signal Protocol will automatically recreate the session on next message. + * Clean up corrupted session for a specific device JID. + * WABA behavior: DELETE sessions WHERE recipient_id=? AND device_id=? + * Only deletes the exact device that was corrupted, not all devices. * * NOTE: This should NOT be called on every Bad MAC error (hot path). * Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does). @@ -500,45 +501,7 @@ export async function cleanupCorruptedSession( repository: SignalRepositoryWithLIDStore, logger: ILogger ): Promise { - const { user, device } = jidDecode(jid) || {} - if (!user) { - logger.warn({ jid }, 'Cannot cleanup session - invalid JID') - return 0 - } - - // Build list of JIDs to delete (primary + secondary devices) - const jidsToDelete: string[] = [] - - // Determine domain type correctly (handle hosted JIDs) - // JID formats: - // - PN: user@s.whatsapp.net - // - LID: user@lid - // - Hosted PN: user@hosted - // - Hosted LID: user@hosted.lid - const isLID = jid.endsWith('@lid') || jid.endsWith('@hosted.lid') - const isHosted = jid.includes('@hosted') - - let domain: string - if (isLID) { - domain = isHosted ? 'hosted.lid' : 'lid' - } else { - domain = isHosted ? 'hosted' : 's.whatsapp.net' - } - - // Primary device (0) - jidsToDelete.push(`${user}@${domain}`) - - // Secondary devices (1-5 common range for Web/Desktop/etc) - for (let i = 1; i <= 5; i++) { - jidsToDelete.push(`${user}:${i}@${domain}`) - } - - // If specific device was identified and > 5, ensure it's included - if (device !== undefined && device > 5) { - jidsToDelete.push(`${user}:${device}@${domain}`) - } - - await repository.deleteSession(jidsToDelete) - - return jidsToDelete.length + await repository.deleteSession([jid]) + logger.info({ jid }, 'Cleaned up corrupted session for specific device') + return 1 } diff --git a/src/Utils/message-retry-manager.ts b/src/Utils/message-retry-manager.ts index ce1ab155..8e446d2b 100644 --- a/src/Utils/message-retry-manager.ts +++ b/src/Utils/message-retry-manager.ts @@ -218,7 +218,7 @@ export class MessageRetryManager { if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) { const now = Date.now() const prevTime = this.sessionRecreateHistory.get(jid) - const MAC_ERROR_COOLDOWN_MS = 10_000 // 10 seconds + const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) { const reasonName = RetryReason[errorCode] || `code_${errorCode}`