From b02390123a6165eaf1ce6449d040f7c0f9d3f918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:31:26 -0300 Subject: [PATCH] fix: detect identity key changes and reset sessions (align with WA Web) (#2307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- src/Signal/libsignal.ts | 80 ++++++++++++++++++++++++++++-- src/Types/Auth.ts | 1 + src/Utils/message-retry-manager.ts | 75 +++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 5 deletions(-) diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index 228dc062..6a985953 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -1,5 +1,7 @@ -/* @ts-ignore */ +// @ts-ignore import * as libsignal from 'libsignal' +// @ts-ignore +import { PreKeyWhisperMessage } from 'libsignal/src/protobufs' import { LRUCache } from 'lru-cache' import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' @@ -20,6 +22,31 @@ import { SenderKeyRecord } from './Group/sender-key-record' import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage } from './Group' import { LIDMappingStore } from './lid-mapping' +/** Extract identity key from PreKeyWhisperMessage for identity change detection */ +function extractIdentityFromPkmsg(ciphertext: Uint8Array): Uint8Array | undefined { + try { + if (!ciphertext || ciphertext.length < 2) { + return undefined + } + + // Version byte check (version 3) + const version = ciphertext[0]! + if ((version & 0xf) !== 3) { + return undefined + } + + // Parse protobuf (skip version byte) + const preKeyProto = PreKeyWhisperMessage.decode(ciphertext.slice(1)) + if (preKeyProto.identityKey?.length === 33) { + return new Uint8Array(preKeyProto.identityKey) + } + + return undefined + } catch { + return undefined + } +} + export function makeLibSignalRepository( auth: SignalAuthState, logger: ILogger, @@ -79,6 +106,18 @@ export function makeLibSignalRepository( const addr = jidToSignalProtocolAddress(jid) const session = new libsignal.SessionCipher(storage, addr) + // Extract and save sender's identity key before decryption for identity change detection + if (type === 'pkmsg') { + const identityKey = extractIdentityFromPkmsg(ciphertext) + if (identityKey) { + const addrStr = addr.toString() + const identityChanged = await storage.saveIdentity(addrStr, identityKey) + if (identityChanged) { + logger.info({ jid, addr: addrStr }, 'identity key changed or new contact, session will be re-established') + } + } + } + async function doDecrypt() { let result: Buffer switch (type) { @@ -359,7 +398,11 @@ const jidToSignalSenderKeyName = (group: string, user: string): SenderKeyName => function signalStorage( { creds, keys }: SignalAuthState, lidMapping: LIDMappingStore -): SenderKeyStore & libsignal.SignalStorage { +): SenderKeyStore & + libsignal.SignalStorage & { + loadIdentityKey(id: string): Promise + saveIdentity(id: string, identityKey: Uint8Array): Promise + } { // Shared function to resolve PN signal address to LID if mapping exists const resolveLIDSignalAddress = async (id: string): Promise => { if (id.includes('.')) { @@ -401,7 +444,38 @@ function signalStorage( await keys.set({ session: { [wireJid]: session.serialize() } }) }, isTrustedIdentity: () => { - return true // todo: implement + return true // TOFU - Trust on First Use (same as WhatsApp Web) + }, + loadIdentityKey: async (id: string) => { + const wireJid = await resolveLIDSignalAddress(id) + const { [wireJid]: key } = await keys.get('identity-key', [wireJid]) + return key || undefined + }, + saveIdentity: async (id: string, identityKey: Uint8Array): Promise => { + const wireJid = await resolveLIDSignalAddress(id) + const { [wireJid]: existingKey } = await keys.get('identity-key', [wireJid]) + + const keysMatch = + existingKey && + existingKey.length === identityKey.length && + existingKey.every((byte, i) => byte === identityKey[i]) + + if (existingKey && !keysMatch) { + // Identity changed - clear session and update key + await keys.set({ + session: { [wireJid]: null }, + 'identity-key': { [wireJid]: identityKey } + }) + return true + } + + if (!existingKey) { + // New contact - Trust on First Use (TOFU) + await keys.set({ 'identity-key': { [wireJid]: identityKey } }) + return true + } + + return false }, loadPreKey: async (id: number | string) => { const keyId = id.toString() diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts index cf50ccfb..28c8c153 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -81,6 +81,7 @@ export type SignalDataTypeMap = { 'lid-mapping': string 'device-list': string[] tctoken: { token: Buffer; timestamp?: string } + 'identity-key': Uint8Array } export type SignalDataSet = { [T in keyof SignalDataTypeMap]?: { [id: string]: SignalDataTypeMap[T] | null } } diff --git a/src/Utils/message-retry-manager.ts b/src/Utils/message-retry-manager.ts index 48be547e..f2eba4b6 100644 --- a/src/Utils/message-retry-manager.ts +++ b/src/Utils/message-retry-manager.ts @@ -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({ 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 */