feat: implement identity key change detection for Signal protocol

This implements the functionality from WhiskeySockets/Baileys PR #2307
with enhanced improvements:

## Core Features
- Extract identity key from PreKeyWhisperMessage before decryption
- Detect when a contact reinstalls WhatsApp (identity key changes)
- Automatically delete old session and recreate on identity change
- Trust On First Use (TOFU) for new contacts

## Improvements over original PR
- LRU cache for identity keys (1000 keys, 30min TTL)
- Integration with existing Circuit Breaker (reset on identity change)
- New Prometheus metrics for observability:
  - signal_identity_changes_total (new/changed)
  - signal_mac_errors_total
  - signal_session_recreations_total
  - signal_identity_key_cache_hits/misses
  - signal_identity_key_operations_ms (histogram)
- New 'identity.changed' event for applications to notify users
- RetryReason enum with MAC_ERROR_CODES and SESSION_ERROR_CODES
- Robust protobuf parsing with validation
- Structured logging with fingerprints

## Technical Details
- Manual protobuf parsing (compatible with any Signal implementation)
- SHA-256 fingerprints for key identification
- Atomic session deletion + identity key save
- Best-effort identity tracking (doesn't fail decryption on errors)

Resolves permanent MAC errors when contacts reinstall WhatsApp.

https://claude.ai/code/session_01SWAcNuGZQmEKyBPYkBhVHg
This commit is contained in:
Claude
2026-01-30 03:35:18 +00:00
parent 228ea998bd
commit 164c7fbe93
5 changed files with 654 additions and 15 deletions
+152 -2
View File
@@ -1,6 +1,7 @@
import { LRUCache } from 'lru-cache'
import type { proto } from '../../WAProto/index.js'
import type { ILogger } from './logger'
import { metrics } from './prometheus-metrics.js'
/** Number of sent messages to cache in memory for handling retry receipts */
const RECENT_MESSAGES_SIZE = 512
@@ -10,6 +11,63 @@ 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
/**
* Retry reason codes from WhatsApp protocol
* These map to the error codes sent in retry receipts
*
* @see https://github.com/WhiskeySockets/Baileys/pull/2307
*/
export enum RetryReason {
/** Unknown or unspecified error */
UnknownError = 0,
/** No Signal session exists for recipient */
SignalErrorNoSession = 1,
/** Invalid key format or corrupted key */
SignalErrorInvalidKey = 2,
/** Invalid pre-key ID (key not found) */
SignalErrorInvalidKeyId = 3,
/** Invalid message - MAC verification failed */
SignalErrorInvalidMessage = 4,
/** Invalid signature on message or key */
SignalErrorInvalidSignature = 5,
/** Message from the future (timestamp issue) */
SignalErrorFutureMessage = 6,
/** Explicit MAC verification failure */
SignalErrorBadMac = 7,
/** Session is corrupted or invalid state */
SignalErrorInvalidSession = 8,
/** Invalid message key (decryption key issue) */
SignalErrorInvalidMsgKey = 9,
/** Bad broadcast ephemeral setting */
BadBroadcastEphemeralSetting = 10,
/** Unknown companion device without pre-key */
UnknownCompanionNoPrekey = 11,
/** ADV (Announcement Delivery Verification) failure */
AdvFailure = 12,
/** Status revoke was delayed */
StatusRevokeDelay = 13,
}
/**
* MAC error codes that indicate identity key mismatch
* These errors occur when the sender's identity key has changed (e.g., reinstalled WhatsApp)
* and require immediate session recreation without waiting for the normal timeout
*/
export const MAC_ERROR_CODES = new Set<RetryReason>([
RetryReason.SignalErrorInvalidMessage,
RetryReason.SignalErrorBadMac,
])
/**
* Session-related error codes that may require session recreation
*/
export const SESSION_ERROR_CODES = new Set<RetryReason>([
RetryReason.SignalErrorNoSession,
RetryReason.SignalErrorInvalidSession,
RetryReason.SignalErrorInvalidKey,
RetryReason.SignalErrorInvalidKeyId,
])
export interface RecentMessageKey {
to: string
id: string
@@ -107,19 +165,65 @@ 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
*
* @param jid - The JID of the recipient
* @param hasSession - Whether a Signal session exists for this JID
* @param errorCode - Optional error code from the retry receipt (indicates type of failure)
* @returns Object with reason string and boolean indicating if session should be recreated
*/
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())
this.statistics.sessionRecreations++
metrics.signalSessionRecreations?.inc({ reason: 'no_session' })
return {
reason: "we don't have a Signal session with them",
recreate: true
}
}
// MAC errors require IMMEDIATE session recreation regardless of history
// This handles the case where contact reinstalled WhatsApp (identity key changed)
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
this.sessionRecreateHistory.set(jid, Date.now())
this.statistics.sessionRecreations++
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
metrics.signalMacErrors?.inc({ action: 'session_recreation' })
metrics.signalSessionRecreations?.inc({ reason: 'mac_error' })
this.logger.warn(
{ jid, errorCode: reasonName },
'MAC error detected, forcing immediate session recreation'
)
return {
reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`,
recreate: true
}
}
// Session-related errors also warrant recreation
if (errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)) {
const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
// For session errors, use a shorter timeout (5 minutes) since these are more severe
const sessionErrorTimeout = 5 * 60 * 1000
if (!prevTime || now - prevTime > sessionErrorTimeout) {
this.sessionRecreateHistory.set(jid, now)
this.statistics.sessionRecreations++
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
metrics.signalSessionRecreations?.inc({ reason: 'session_error' })
return {
reason: `Session error (${reasonName})`,
recreate: true
}
}
}
const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
@@ -127,6 +231,7 @@ export class MessageRetryManager {
if (!prevTime || now - prevTime > RECREATE_SESSION_TIMEOUT) {
this.sessionRecreateHistory.set(jid, now)
this.statistics.sessionRecreations++
metrics.signalSessionRecreations?.inc({ reason: 'timeout_exceeded' })
return {
reason: 'retry count > 1 and over an hour since last recreation',
recreate: true
@@ -136,6 +241,51 @@ export class MessageRetryManager {
return { reason: '', recreate: false }
}
/**
* Parse error code from retry receipt attribute
*
* @param errorAttr - The error attribute string from the retry receipt
* @returns Parsed RetryReason or undefined if invalid
*/
parseRetryErrorCode(errorAttr: string | undefined): RetryReason | undefined {
if (errorAttr === undefined || errorAttr === '') {
return undefined
}
const code = parseInt(errorAttr, 10)
if (Number.isNaN(code)) {
return undefined
}
// Validate code is within known range
if (code >= RetryReason.UnknownError && code <= RetryReason.StatusRevokeDelay) {
return code as RetryReason
}
// Unknown code, treat as UnknownError
return RetryReason.UnknownError
}
/**
* Check if an error code indicates a MAC verification failure
*
* @param errorCode - The retry error code to check
* @returns True if this is a MAC error requiring immediate session recreation
*/
isMacError(errorCode: RetryReason | undefined): boolean {
return errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)
}
/**
* Check if an error code indicates a session-related failure
*
* @param errorCode - The retry error code to check
* @returns True if this is a session error
*/
isSessionError(errorCode: RetryReason | undefined): boolean {
return errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)
}
/**
* Increment retry counter for a message
*/
+29
View File
@@ -1601,6 +1601,35 @@ export const metrics = {
new Gauge('prekey_count', 'Current prekey count')
),
// ========== Signal Identity Metrics ==========
/** Total identity key changes detected (contact reinstalled WhatsApp) */
signalIdentityChanges: baileysMetrics.register(
new Counter('signal_identity_changes_total', 'Total identity key changes detected', ['type'])
),
/** Total Signal MAC errors encountered */
signalMacErrors: baileysMetrics.register(
new Counter('signal_mac_errors_total', 'Total Signal MAC verification errors', ['action'])
),
/** Total Signal session recreations by reason */
signalSessionRecreations: baileysMetrics.register(
new Counter('signal_session_recreations_total', 'Total Signal session recreations', ['reason'])
),
/** Identity key cache statistics */
signalIdentityKeyCacheHits: baileysMetrics.register(
new Counter('signal_identity_key_cache_hits_total', 'Total identity key cache hits')
),
signalIdentityKeyCacheMisses: baileysMetrics.register(
new Counter('signal_identity_key_cache_misses_total', 'Total identity key cache misses')
),
/** Identity key operations latency */
signalIdentityKeyOperations: baileysMetrics.register(
new Histogram('signal_identity_key_operations_ms', 'Identity key operation latency in ms', ['operation'], [1, 5, 10, 25, 50, 100])
),
/** Current identity key cache size */
signalIdentityKeyCacheSize: baileysMetrics.register(
new Gauge('signal_identity_key_cache_size', 'Current identity key cache size')
),
// ========== Cache Metrics ==========
cacheHits: baileysMetrics.register(
new Counter('cache_hits_total', 'Total cache hits', ['cache'])