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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user