diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index f7385565..fb9e64d2 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -36,6 +36,7 @@ import { getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, + handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, @@ -564,21 +565,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { await uploadPreKeys() } } else { - const identityNode = getBinaryNodeChild(node, 'identity') - if (identityNode) { - logger.info({ jid: from }, 'identity changed') - if (identityAssertDebounce.get(from!)) { - logger.debug({ jid: from }, 'skipping identity assert (debounced)') - return - } + const result = await handleIdentityChange(node, { + meId: authState.creds.me?.id, + meLid: authState.creds.me?.lid, + validateSession: signalRepository.validateSession, + assertSessions, + debounceCache: identityAssertDebounce, + logger + }) - identityAssertDebounce.set(from!, true) - try { - await assertSessions([from!], true) - } catch (error) { - logger.warn({ error, jid: from }, 'failed to assert sessions after identity change') - } - } else { + if (result.action === 'no_identity_node') { logger.info({ node }, 'unknown encrypt notification') } } @@ -1239,9 +1235,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { await decrypt() // message failed to decrypt if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') { - // Handle "Missing keys" - standard decryption failure, just ACK + // Handle "Missing keys" - standard decryption failure + // Return NACK with parsing error to signal the issue if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) { - return sendMessageAck(node) + return sendMessageAck(node, NACK_REASONS.ParsingError) } // Handle "Message absent from node" - likely a CTWA (Click-to-WhatsApp) ads message diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index ce0a69c8..9f74429f 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -50,6 +50,10 @@ export const BufferJSON = { export const getKeyAuthor = (key: WAMessageKey | undefined | null, meId = 'me') => (key?.fromMe ? meId : key?.participantAlt || key?.remoteJidAlt || key?.participant || key?.remoteJid) || '' +export const isStringNullOrEmpty = (value: string | null | undefined): value is null | undefined | '' => + // eslint-disable-next-line eqeqeq + value == null || value === '' + export const writeRandomPadMax16 = (msg: Uint8Array) => { const pad = randomBytes(1) const padLength = (pad[0]! & 0x0f) + 1 diff --git a/src/Utils/identity-change-handler.ts b/src/Utils/identity-change-handler.ts new file mode 100644 index 00000000..d185b068 --- /dev/null +++ b/src/Utils/identity-change-handler.ts @@ -0,0 +1,181 @@ +/** + * Identity Change Handler + * + * Handles WhatsApp identity change notifications which occur when a contact's + * encryption keys change. This typically happens when: + * - User reinstalls WhatsApp + * - User changes devices + * - User's phone number verification expires and is re-verified + * + * This module centralizes the identity change logic to: + * - Prevent duplicate session refreshes via debouncing + * - Skip unnecessary refreshes for companion devices + * - Handle offline notification scenarios correctly + * - Provide clear result types for monitoring and debugging + * + * @module identity-change-handler + * @see https://github.com/WhiskeySockets/Baileys/issues/2132 + */ + +import NodeCache from '@cacheable/node-cache' +import { areJidsSameUser, type BinaryNode, getBinaryNodeChild, jidDecode } from '../WABinary' +import { isStringNullOrEmpty } from './generics' +import type { ILogger } from './logger' + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Result type for identity change handling operations. + * Each action variant indicates a specific outcome with relevant context. + */ +export type IdentityChangeResult = + | { action: 'no_identity_node' } + | { action: 'invalid_notification' } + | { action: 'skipped_companion_device'; device: number } + | { action: 'skipped_self_primary' } + | { action: 'debounced' } + | { action: 'skipped_offline' } + | { action: 'skipped_no_session' } + | { action: 'session_refreshed' } + | { action: 'session_refresh_failed'; error: unknown } + +/** + * Context required for identity change handling. + * Provides access to session management and logging capabilities. + */ +export type IdentityChangeContext = { + /** Current user's phone number JID (e.g., "5511999999999@s.whatsapp.net") */ + meId: string | undefined + /** Current user's LID (Logical ID) if available */ + meLid: string | undefined + /** Function to validate if a session exists for a JID */ + validateSession: (jid: string) => Promise<{ exists: boolean; reason?: string }> + /** Function to assert/refresh sessions for given JIDs */ + assertSessions: (jids: string[], force?: boolean) => Promise + /** Cache for debouncing identity change processing */ + debounceCache: NodeCache + /** Logger instance for debugging and monitoring */ + logger: ILogger +} + +// ============================================================================ +// MAIN HANDLER +// ============================================================================ + +/** + * Handles an identity change notification from WhatsApp. + * + * This function processes identity change events with the following logic: + * + * 1. **Validation**: Ensures the notification has required fields (from, identity node) + * 2. **Companion Device Filter**: Skips notifications from companion devices (device > 0) + * as they don't require session refresh + * 3. **Self-Primary Skip**: Skips notifications for the current user's own identity + * 4. **Debouncing**: Prevents duplicate processing for the same JID within TTL window + * 5. **Session Check**: Verifies an existing session exists before attempting refresh + * 6. **Offline Handling**: Skips refresh during offline notification processing + * 7. **Session Refresh**: Attempts to refresh the session with force flag + * + * @param node - The binary node containing the identity change notification + * @param ctx - Context object with required dependencies + * @returns Promise resolving to the result of the identity change handling + * + * @example + * ```typescript + * const result = await handleIdentityChange(notificationNode, { + * meId: '5511999999999@s.whatsapp.net', + * meLid: undefined, + * validateSession: async (jid) => authState.keys.get('session', [jid]), + * assertSessions: async (jids, force) => assertSession(jids, force), + * debounceCache: new NodeCache({ stdTTL: 5 }), + * logger: pino() + * }) + * + * switch (result.action) { + * case 'session_refreshed': + * console.log('Session successfully refreshed') + * break + * case 'session_refresh_failed': + * console.error('Failed to refresh session:', result.error) + * break + * // ... handle other cases + * } + * ``` + */ +export async function handleIdentityChange( + node: BinaryNode, + ctx: IdentityChangeContext +): Promise { + const from = node.attrs.from + if (!from) { + return { action: 'invalid_notification' } + } + + // Check for identity node presence + const identityNode = getBinaryNodeChild(node, 'identity') + if (!identityNode) { + return { action: 'no_identity_node' } + } + + ctx.logger.info({ jid: from }, 'identity changed') + + // Skip companion devices - they don't need session refresh + const decoded = jidDecode(from) + if (decoded?.device && decoded.device !== 0) { + ctx.logger.debug( + { jid: from, device: decoded.device }, + 'ignoring identity change from companion device' + ) + return { action: 'skipped_companion_device', device: decoded.device } + } + + // Skip self-primary identity changes + const isSelfPrimary = ctx.meId && ( + areJidsSameUser(from, ctx.meId) || + (ctx.meLid && areJidsSameUser(from, ctx.meLid)) + ) + if (isSelfPrimary) { + ctx.logger.info({ jid: from }, 'self primary identity changed') + return { action: 'skipped_self_primary' } + } + + // Debounce to prevent duplicate processing + if (ctx.debounceCache.get(from)) { + ctx.logger.debug({ jid: from }, 'skipping identity assert (debounced)') + return { action: 'debounced' } + } + + // Mark as processing in debounce cache + ctx.debounceCache.set(from, true) + + // Check if we have an existing session to refresh + const hasExistingSession = await ctx.validateSession(from) + if (!hasExistingSession.exists) { + ctx.logger.debug({ jid: from }, 'no old session, skipping session refresh') + return { action: 'skipped_no_session' } + } + + ctx.logger.debug({ jid: from }, 'old session exists, will refresh session') + + // Skip refresh during offline notification processing + // Offline notifications are processed in batch and shouldn't trigger immediate refreshes + const isOfflineNotification = !isStringNullOrEmpty(node.attrs.offline) + if (isOfflineNotification) { + ctx.logger.debug({ jid: from }, 'skipping session refresh during offline processing') + return { action: 'skipped_offline' } + } + + // Attempt session refresh + try { + await ctx.assertSessions([from], true) + return { action: 'session_refreshed' } + } catch (error) { + ctx.logger.warn( + { error, jid: from }, + 'failed to assert sessions after identity change' + ) + return { action: 'session_refresh_failed', error } + } +} diff --git a/src/Utils/index.ts b/src/Utils/index.ts index 1826cf29..70bd687f 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -17,6 +17,9 @@ export * from './process-message' export * from './message-retry-manager' export * from './browser-utils' +// === Identity and Session Management === +export * from './identity-change-handler' + // === Observability and Resilience Utilities === // Structured logging diff --git a/src/__tests__/Socket/identity-change-handling.test.ts b/src/__tests__/Socket/identity-change-handling.test.ts new file mode 100644 index 00000000..3f12ae14 --- /dev/null +++ b/src/__tests__/Socket/identity-change-handling.test.ts @@ -0,0 +1,207 @@ +import NodeCache from '@cacheable/node-cache' +import { jest } from '@jest/globals' +import P from 'pino' +import { handleIdentityChange, type IdentityChangeContext } from '../../Utils/identity-change-handler' +import { type BinaryNode } from '../../WABinary' + +const logger = P({ level: 'silent' }) + +type ValidateSessionFn = (jid: string) => Promise<{ exists: boolean; reason?: string }> +type AssertSessionsFn = (jids: string[], force?: boolean) => Promise + +describe('Identity Change Handling', () => { + let mockValidateSession: jest.Mock + let mockAssertSessions: jest.Mock + let identityAssertDebounce: NodeCache + let mockMeId: string + let mockMeLid: string | undefined + + function createIdentityChangeNode(from: string, offline?: string): BinaryNode { + return { + tag: 'notification', + attrs: { + from, + type: 'encrypt', + ...(offline !== undefined ? { offline } : {}) + }, + content: [ + { + tag: 'identity', + attrs: {}, + content: Buffer.from('test-identity-key') + } + ] + } + } + + function createContext(): IdentityChangeContext { + return { + meId: mockMeId, + meLid: mockMeLid, + validateSession: mockValidateSession, + assertSessions: mockAssertSessions, + debounceCache: identityAssertDebounce, + logger + } + } + + beforeEach(() => { + jest.clearAllMocks() + mockValidateSession = jest.fn() + mockAssertSessions = jest.fn() + identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false }) + mockMeId = 'myuser@s.whatsapp.net' + mockMeLid = 'mylid@lid' + }) + + describe('Core Checks', () => { + it('should skip companion devices (device > 0)', async () => { + const node = createIdentityChangeNode('user:5@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_companion_device') + }) + + it('should process primary device (device 0 or undefined)', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(result.action).toBe('session_refreshed') + }) + + it('should skip self-primary identity (PN match)', async () => { + const node = createIdentityChangeNode('myuser@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_self_primary') + }) + + it('should skip self-primary identity (LID match)', async () => { + const node = createIdentityChangeNode('mylid@lid') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_self_primary') + }) + + it('should skip when no existing session', async () => { + mockValidateSession.mockResolvedValue({ exists: false }) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(mockAssertSessions).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_no_session') + }) + + it('should skip session refresh during offline processing', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + + const node = createIdentityChangeNode('user@s.whatsapp.net', '0') + const result = await handleIdentityChange(node, createContext()) + + expect(mockAssertSessions).not.toHaveBeenCalled() + expect(result.action).toBe('skipped_offline') + }) + + it('should refresh session when online with existing session', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(mockAssertSessions).toHaveBeenCalledWith(['user@s.whatsapp.net'], true) + expect(result.action).toBe('session_refreshed') + }) + }) + + describe('Debounce', () => { + it('should debounce multiple identity changes for the same JID', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + + const result1 = await handleIdentityChange(node, createContext()) + expect(result1.action).toBe('session_refreshed') + + const result2 = await handleIdentityChange(node, createContext()) + expect(result2.action).toBe('debounced') + expect(mockAssertSessions).toHaveBeenCalledTimes(1) + }) + + it('should allow different JIDs to process independently', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const result1 = await handleIdentityChange(createIdentityChangeNode('user1@s.whatsapp.net'), createContext()) + const result2 = await handleIdentityChange(createIdentityChangeNode('user2@s.whatsapp.net'), createContext()) + + expect(result1.action).toBe('session_refreshed') + expect(result2.action).toBe('session_refreshed') + expect(mockAssertSessions).toHaveBeenCalledTimes(2) + }) + }) + + describe('Error Handling', () => { + it('should handle assertSessions failure gracefully', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + const testError = new Error('Session assertion failed') + mockAssertSessions.mockRejectedValue(testError) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const result = await handleIdentityChange(node, createContext()) + + expect(result.action).toBe('session_refresh_failed') + expect((result as { error: unknown }).error).toBe(testError) + }) + + it('should propagate validateSession errors', async () => { + mockValidateSession.mockRejectedValue(new Error('Database error')) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + await expect(handleIdentityChange(node, createContext())).rejects.toThrow('Database error') + }) + }) + + describe('Edge Cases', () => { + it('should return invalid_notification when from is missing', async () => { + const node: BinaryNode = { + tag: 'notification', + attrs: { type: 'encrypt' }, + content: [{ tag: 'identity', attrs: {}, content: Buffer.from('key') }] + } + + const result = await handleIdentityChange(node, createContext()) + expect(result.action).toBe('invalid_notification') + }) + + it('should return no_identity_node when identity child is missing', async () => { + const node: BinaryNode = { + tag: 'notification', + attrs: { from: 'user@s.whatsapp.net', type: 'encrypt' }, + content: [] + } + + const result = await handleIdentityChange(node, createContext()) + expect(result.action).toBe('no_identity_node') + }) + + it('should handle LID JIDs correctly', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + mockAssertSessions.mockResolvedValue(true) + + const node = createIdentityChangeNode('12345@lid') + const result = await handleIdentityChange(node, createContext()) + + expect(mockValidateSession).toHaveBeenCalledWith('12345@lid') + expect(result.action).toBe('session_refreshed') + }) + }) +})