Files
InfiniteAPI/src/Utils/message-retry-manager.ts
T
João Lucas b02390123a fix: detect identity key changes and reset sessions (align with WA Web) (#2307)
* feat(signal): add RetryReason enum and MAC error-based session recreation

* feat(signal): add identity change detection with automatic session clearing

* fix(signal): integrate identity change detection with pkmsg decryption

This completes the identity change detection implementation by actually
calling saveIdentity() during pkmsg decryption, which is CRITICAL for
the feature to work.

Changes:
- Add extractIdentityFromPkmsg() function that parses PreKeyWhisperMessage
  protobuf to extract sender's identity key (33 bytes)
- Call saveIdentity() BEFORE decryption in decryptMessage() for pkmsg type
- Log when identity change is detected

Flow:
1. Receive pkmsg from sender
2. Extract identity key from PreKeyWhisperMessage protobuf
3. Call storage.saveIdentity() which compares with stored key
4. If key changed → session is cleared atomically
5. Decryption proceeds with re-established session

This matches WhatsApp Web's behavior where extractIdentityKey is called
before handleNewSession (GysEGRAXCvh.js:40917, 48815).

Ref: WhatsApp Web's extractIdentityKey (GysEGRAXCvh.js:48976-48998)
2026-02-05 21:31:26 +02:00

296 lines
8.1 KiB
TypeScript

import { LRUCache } from 'lru-cache'
import type { proto } from '../../WAProto/index.js'
import type { ILogger } from './logger'
/** Number of sent messages to cache in memory for handling retry receipts */
const RECENT_MESSAGES_SIZE = 512
const MESSAGE_KEY_SEPARATOR = '\u0000'
/** Timeout for session recreation - 1 hour */
const RECREATE_SESSION_TIMEOUT = 60 * 60 * 1000 // 1 hour in milliseconds
const PHONE_REQUEST_DELAY = 3000
export interface RecentMessageKey {
to: string
id: string
}
export interface RecentMessage {
message: proto.IMessage
timestamp: number
}
export interface SessionRecreateHistory {
[jid: string]: number // timestamp
}
export interface RetryCounter {
[messageId: string]: number
}
export type PendingPhoneRequest = Record<string, ReturnType<typeof setTimeout>>
export interface RetryStatistics {
totalRetries: number
successfulRetries: number
failedRetries: number
mediaRetries: number
sessionRecreations: number
phoneRequests: number
}
// Retry reason codes matching WhatsApp Web's Signal error codes.
export enum RetryReason {
UnknownError = 0,
SignalErrorNoSession = 1,
SignalErrorInvalidKey = 2,
SignalErrorInvalidKeyId = 3,
/** MAC verification failed - most common cause of decryption failures */
SignalErrorInvalidMessage = 4,
SignalErrorInvalidSignature = 5,
SignalErrorFutureMessage = 6,
/** Explicit MAC failure - session is definitely out of sync */
SignalErrorBadMac = 7,
SignalErrorInvalidSession = 8,
SignalErrorInvalidMsgKey = 9,
BadBroadcastEphemeralSetting = 10,
UnknownCompanionNoPrekey = 11,
AdvFailure = 12,
StatusRevokeDelay = 13
}
/** Error codes that indicate a MAC failure and require immediate session recreation */
const MAC_ERROR_CODES = new Set([RetryReason.SignalErrorInvalidMessage, RetryReason.SignalErrorBadMac])
export class MessageRetryManager {
private recentMessagesMap = new LRUCache<string, RecentMessage>({
max: RECENT_MESSAGES_SIZE,
ttl: 5 * 60 * 1000,
ttlAutopurge: true,
dispose: (_value: RecentMessage, key: string) => {
const separatorIndex = key.lastIndexOf(MESSAGE_KEY_SEPARATOR)
if (separatorIndex > -1) {
const messageId = key.slice(separatorIndex + MESSAGE_KEY_SEPARATOR.length)
this.messageKeyIndex.delete(messageId)
}
}
})
private messageKeyIndex = new Map<string, string>()
private sessionRecreateHistory = new LRUCache<string, number>({
ttl: RECREATE_SESSION_TIMEOUT * 2,
ttlAutopurge: true
})
private retryCounters = new LRUCache<string, number>({
ttl: 15 * 60 * 1000,
ttlAutopurge: true,
updateAgeOnGet: true
}) // 15 minutes TTL
private pendingPhoneRequests: PendingPhoneRequest = {}
private readonly maxMsgRetryCount: number = 5
private statistics: RetryStatistics = {
totalRetries: 0,
successfulRetries: 0,
failedRetries: 0,
mediaRetries: 0,
sessionRecreations: 0,
phoneRequests: 0
}
constructor(
private logger: ILogger,
maxMsgRetryCount: number
) {
this.maxMsgRetryCount = maxMsgRetryCount
}
/**
* Add a recent message to the cache for retry handling
*/
addRecentMessage(to: string, id: string, message: proto.IMessage): void {
const key: RecentMessageKey = { to, id }
const keyStr = this.keyToString(key)
// Add new message
this.recentMessagesMap.set(keyStr, {
message,
timestamp: Date.now()
})
this.messageKeyIndex.set(id, keyStr)
this.logger.debug(`Added message to retry cache: ${to}/${id}`)
}
/**
* Get a recent message from the cache
*/
getRecentMessage(to: string, id: string): RecentMessage | undefined {
const key: RecentMessageKey = { to, id }
const keyStr = this.keyToString(key)
return this.recentMessagesMap.get(keyStr)
}
/**
* Check if a session should be recreated based on retry count, history, and error code.
* MAC errors (codes 4 and 7) trigger immediate session recreation regardless of timeout.
*/
shouldRecreateSession(
jid: string,
hasSession: boolean,
errorCode?: RetryReason
): { reason: string; recreate: boolean } {
// If we don't have a session, always recreate
if (!hasSession) {
this.sessionRecreateHistory.set(jid, Date.now())
this.statistics.sessionRecreations++
return {
reason: "we don't have a Signal session with them",
recreate: true
}
}
// IMMEDIATE recreation for MAC errors - session is definitely out of sync
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
this.sessionRecreateHistory.set(jid, Date.now())
this.statistics.sessionRecreations++
this.logger.warn(
{ jid, errorCode: RetryReason[errorCode] },
'MAC error detected, forcing immediate session recreation'
)
return {
reason: `MAC error (code ${errorCode}: ${RetryReason[errorCode]}), immediate session recreation`,
recreate: true
}
}
const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
// If no previous recreation or it's been more than an hour
if (!prevTime || now - prevTime > RECREATE_SESSION_TIMEOUT) {
this.sessionRecreateHistory.set(jid, now)
this.statistics.sessionRecreations++
return {
reason: 'retry count > 1 and over an hour since last recreation',
recreate: true
}
}
return { reason: '', recreate: false }
}
/**
* Parse error code from retry receipt's retry node.
* Returns undefined if no error code is present.
*/
parseRetryErrorCode(errorAttr: string | undefined): RetryReason | undefined {
if (errorAttr === undefined || errorAttr === '') {
return undefined
}
const code = parseInt(errorAttr, 10)
if (Number.isNaN(code)) {
return undefined
}
// Validate it's a known RetryReason
if (code >= RetryReason.UnknownError && code <= RetryReason.StatusRevokeDelay) {
return code as RetryReason
}
return RetryReason.UnknownError
}
/**
* Check if an error code indicates a MAC failure
*/
isMacError(errorCode: RetryReason | undefined): boolean {
return errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)
}
/**
* Increment retry counter for a message
*/
incrementRetryCount(messageId: string): number {
this.retryCounters.set(messageId, (this.retryCounters.get(messageId) || 0) + 1)
this.statistics.totalRetries++
return this.retryCounters.get(messageId)!
}
/**
* Get retry count for a message
*/
getRetryCount(messageId: string): number {
return this.retryCounters.get(messageId) || 0
}
/**
* Check if message has exceeded maximum retry attempts
*/
hasExceededMaxRetries(messageId: string): boolean {
return this.getRetryCount(messageId) >= this.maxMsgRetryCount
}
/**
* Mark retry as successful
*/
markRetrySuccess(messageId: string): void {
this.statistics.successfulRetries++
// Clean up retry counter for successful message
this.retryCounters.delete(messageId)
this.cancelPendingPhoneRequest(messageId)
this.removeRecentMessage(messageId)
}
/**
* Mark retry as failed
*/
markRetryFailed(messageId: string): void {
this.statistics.failedRetries++
this.retryCounters.delete(messageId)
this.cancelPendingPhoneRequest(messageId)
this.removeRecentMessage(messageId)
}
/**
* Schedule a phone request with delay
*/
schedulePhoneRequest(messageId: string, callback: () => void, delay: number = PHONE_REQUEST_DELAY): void {
// Cancel any existing request for this message
this.cancelPendingPhoneRequest(messageId)
this.pendingPhoneRequests[messageId] = setTimeout(() => {
delete this.pendingPhoneRequests[messageId]
this.statistics.phoneRequests++
callback()
}, delay)
this.logger.debug(`Scheduled phone request for message ${messageId} with ${delay}ms delay`)
}
/**
* Cancel pending phone request
*/
cancelPendingPhoneRequest(messageId: string): void {
const timeout = this.pendingPhoneRequests[messageId]
if (timeout) {
clearTimeout(timeout)
delete this.pendingPhoneRequests[messageId]
this.logger.debug(`Cancelled pending phone request for message ${messageId}`)
}
}
private keyToString(key: RecentMessageKey): string {
return `${key.to}${MESSAGE_KEY_SEPARATOR}${key.id}`
}
private removeRecentMessage(messageId: string): void {
const keyStr = this.messageKeyIndex.get(messageId)
if (!keyStr) {
return
}
this.recentMessagesMap.delete(keyStr)
this.messageKeyIndex.delete(messageId)
}
}