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)
This commit is contained in:
João Lucas
2026-02-05 16:31:26 -03:00
committed by GitHub
parent b5c174111f
commit b02390123a
3 changed files with 151 additions and 5 deletions
+73 -2
View File
@@ -39,6 +39,29 @@ export interface RetryStatistics {
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,
@@ -107,9 +130,14 @@ export class MessageRetryManager {
}
/**
* Check if a session should be recreated based on retry count and history
* 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): { reason: string; recreate: boolean } {
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())
@@ -120,6 +148,20 @@ export class MessageRetryManager {
}
}
// 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)
@@ -136,6 +178,35 @@ export class MessageRetryManager {
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
*/