From d514764686dd6edda58cf5fa982cf424e38ba641 Mon Sep 17 00:00:00 2001 From: Matheus Filype <67132916+Santosl2@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:51:47 -0300 Subject: [PATCH 1/5] feat: send unified session (#2294) * fix: improve message resend logic by adding checks for message IDs * Revert "fix: improve message resend logic by adding checks for message IDs" This reverts commit c03f9d8e6fc6cbfbb9d1f8f67c169700e704213d. * feat: add unified session handling and time constants * refactor: improve socket variable destructuring and presence update logic * fix: remove unnecessary semicolons in socket and time constants definitions * fix: handle invalid server time offset parsing in makeSocket function --- src/Defaults/index.ts | 7 ++++++ src/Socket/chats.ts | 21 +++++++++++++--- src/Socket/socket.ts | 57 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 10d140fc..be3750d9 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -137,3 +137,10 @@ export const DEFAULT_CACHE_TTLS = { CALL_OFFER: 5 * 60, // 5 minutes USER_DEVICES: 5 * 60 // 5 minutes } + +export const TimeMs = { + Minute: 60 * 1000, + Hour: 60 * 60 * 1000, + Day: 24 * 60 * 60 * 1000, + Week: 7 * 24 * 60 * 60 * 1000 +} diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index 8e2de602..efb38838 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -68,7 +68,17 @@ export const makeChatsSocket = (config: SocketConfig) => { getMessage } = config const sock = makeSocket(config) - const { ev, ws, authState, generateMessageTag, sendNode, query, signalRepository, onUnexpectedError } = sock + const { + ev, + ws, + authState, + generateMessageTag, + sendNode, + query, + signalRepository, + onUnexpectedError, + sendUnifiedSession + } = sock let privacySettings: { [_: string]: string } | undefined @@ -659,13 +669,18 @@ export const makeChatsSocket = (config: SocketConfig) => { const sendPresenceUpdate = async (type: WAPresence, toJid?: string) => { const me = authState.creds.me! - if (type === 'available' || type === 'unavailable') { + const isAvailableType = type === 'available' + if (isAvailableType || type === 'unavailable') { if (!me.name) { logger.warn('no name present, ignoring presence update request...') return } - ev.emit('connection.update', { isOnline: type === 'available' }) + ev.emit('connection.update', { isOnline: isAvailableType }) + + if (isAvailableType) { + void sendUnifiedSession() + } await sendNode({ tag: 'presence', diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index b258fc40..87c65a5a 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -11,6 +11,7 @@ import { MIN_UPLOAD_INTERVAL, NOISE_WA_HEADER, PROCESSABLE_HISTORY_TYPES, + TimeMs, UPLOAD_TIMEOUT } from '../Defaults' import type { LIDMapping, SocketConfig } from '../Types' @@ -77,6 +78,8 @@ export const makeSocket = (config: SocketConfig) => { const publicWAMBuffer = new BinaryInfo() + let serverTimeOffsetMs = 0 + const uqTagId = generateMdTagPrefix() const generateMessageTag = () => `${uqTagId}${epoch++}` @@ -895,6 +898,7 @@ export const makeSocket = (config: SocketConfig) => { ws.on('CB:iq,,pair-success', async (stanza: BinaryNode) => { logger.debug('pair success recv') try { + updateServerTimeOffset(stanza) const { reply, creds: updatedCreds } = configureSuccessfulPairing(stanza, creds) logger.info( @@ -906,6 +910,7 @@ export const makeSocket = (config: SocketConfig) => { ev.emit('connection.update', { isNewLogin: true, qr: undefined }) await sendNode(reply) + void sendUnifiedSession() } catch (error: any) { logger.info({ trace: error.stack }, 'error in pairing') void end(error) @@ -914,6 +919,7 @@ export const makeSocket = (config: SocketConfig) => { // login complete ws.on('CB:success', async (node: BinaryNode) => { try { + updateServerTimeOffset(node) await uploadPreKeysToServerIfRequired() await sendPassiveIq('active') @@ -933,6 +939,7 @@ export const makeSocket = (config: SocketConfig) => { ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } }) ev.emit('connection.update', { connection: 'open' }) + void sendUnifiedSession() if (node.attrs.lid && authState.creds.me?.id) { const myLID = node.attrs.lid @@ -1041,6 +1048,54 @@ export const makeSocket = (config: SocketConfig) => { Object.assign(creds, update) }) + const updateServerTimeOffset = ({ attrs }: BinaryNode) => { + const tValue = attrs?.t + if (!tValue) { + return + } + + const parsed = Number(tValue) + if (Number.isNaN(parsed) || parsed <= 0) { + return + } + + const localMs = Date.now() + serverTimeOffsetMs = parsed * 1000 - localMs + logger.debug({ offset: serverTimeOffsetMs }, 'calculated server time offset') + } + + const getUnifiedSessionId = () => { + const offsetMs = 3 * TimeMs.Day + const now = Date.now() + serverTimeOffsetMs + const id = (now + offsetMs) % TimeMs.Week + return id.toString() + } + + const sendUnifiedSession = async () => { + if (!ws.isOpen) { + return + } + + const node = { + tag: 'ib', + attrs: {}, + content: [ + { + tag: 'unified_session', + attrs: { + id: getUnifiedSessionId() + } + } + ] + } + + try { + await sendNode(node) + } catch (error) { + logger.debug({ error }, 'failed to send unified_session telemetry') + } + } + return { type: 'md' as 'md', ws, @@ -1064,6 +1119,8 @@ export const makeSocket = (config: SocketConfig) => { digestKeyBundle, rotateSignedPreKey, requestPairingCode, + updateServerTimeOffset, + sendUnifiedSession, wamBuffer: publicWAMBuffer, /** Waits for the connection to WA to reach a state */ waitForConnectionUpdate: bindWaitForConnectionUpdate(ev), From ffc019fb516a467c3639dee291136591e3314f4f Mon Sep 17 00:00:00 2001 From: Ahmed Alwahib Date: Sun, 25 Jan 2026 02:52:36 +0300 Subject: [PATCH 2/5] fix: align noise-handler buffer types for Baileys build (#2284) * fix: align noise-handler buffer types for Baileys build * Align noise handler buffer types * Clarify noise handler buffer typing --- src/Utils/noise-handler.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Utils/noise-handler.ts b/src/Utils/noise-handler.ts index feb8a6c2..02db94d3 100644 --- a/src/Utils/noise-handler.ts +++ b/src/Utils/noise-handler.ts @@ -64,9 +64,9 @@ export const makeNoiseHandler = ({ const data = Buffer.from(NOISE_MODE) let hash = data.byteLength === 32 ? data : sha256(data) - let salt = hash - let encKey = hash - let decKey = hash + let salt: Buffer = hash + let encKey: Buffer = hash + let decKey: Buffer = hash let counter = 0 let sentIntro = false @@ -116,23 +116,23 @@ export const makeNoiseHandler = ({ return result } - const localHKDF = async (data: Uint8Array) => { + const localHKDF = async (data: Uint8Array): Promise<[Buffer, Buffer]> => { const key = await hkdf(Buffer.from(data), 64, { salt, info: '' }) return [key.subarray(0, 32), key.subarray(32)] } const mixIntoKey = async (data: Uint8Array) => { const [write, read] = await localHKDF(data) - salt = write! - encKey = read! - decKey = read! + salt = write + encKey = read + decKey = read counter = 0 } const finishInit = async () => { isWaitingForTransport = true const [write, read] = await localHKDF(new Uint8Array(0)) - transport = new TransportState(write!, read!) + transport = new TransportState(write, read) isWaitingForTransport = false logger.trace('Noise handler transitioned to Transport state') From fa2a837a4acefa859f5b4f86d02125b91eb0b9b1 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 11:06:15 -0300 Subject: [PATCH 3/5] perf: reduce DB calls during sync with caching and batching (#2316) * perf: reduce DB calls during sync with caching and batching * refactor: clean up comments and improve LID-PN mapping storage during history sync --- src/Signal/lid-mapping.ts | 126 ++++++++++++++++++++++++++--------- src/Socket/chats.ts | 18 ++++- src/Types/Events.ts | 1 + src/Utils/process-message.ts | 9 +-- 4 files changed, 113 insertions(+), 41 deletions(-) diff --git a/src/Signal/lid-mapping.ts b/src/Signal/lid-mapping.ts index 2e8b2cd9..75045370 100644 --- a/src/Signal/lid-mapping.ts +++ b/src/Signal/lid-mapping.ts @@ -14,6 +14,9 @@ export class LIDMappingStore { private pnToLIDFunc?: (jids: string[]) => Promise + private readonly inflightLIDLookups = new Map>() + private readonly inflightPNLookups = new Map>() + constructor( keys: SignalKeyStoreWithTransaction, logger: ILogger, @@ -24,12 +27,10 @@ export class LIDMappingStore { this.logger = logger } - /** - * Store LID-PN mapping - USER LEVEL - */ async storeLIDPNMappings(pairs: LIDMapping[]): Promise { - // Validate inputs - const pairMap: { [_: string]: string } = {} + if (pairs.length === 0) return + + const validatedPairs: Array<{ pnUser: string; lidUser: string }> = [] for (const { lid, pn } of pairs) { if (!((isLidUser(lid) && isPnUser(pn)) || (isPnUser(lid) && isLidUser(pn)))) { this.logger.warn(`Invalid LID-PN mapping: ${lid}, ${pn}`) @@ -38,24 +39,43 @@ export class LIDMappingStore { const lidDecoded = jidDecode(lid) const pnDecoded = jidDecode(pn) - if (!lidDecoded || !pnDecoded) continue - const pnUser = pnDecoded.user - const lidUser = lidDecoded.user + validatedPairs.push({ pnUser: pnDecoded.user, lidUser: lidDecoded.user }) + } - let existingLidUser = this.mappingCache.get(`pn:${pnUser}`) - if (!existingLidUser) { - this.logger.trace(`Cache miss for PN user ${pnUser}; checking database`) - const stored = await this.keys.get('lid-mapping', [pnUser]) - existingLidUser = stored[pnUser] + if (validatedPairs.length === 0) return + + const cacheMissSet = new Set() + const existingMappings = new Map() + + for (const { pnUser } of validatedPairs) { + const cached = this.mappingCache.get(`pn:${pnUser}`) + if (cached) { + existingMappings.set(pnUser, cached) + } else { + cacheMissSet.add(pnUser) + } + } + + if (cacheMissSet.size > 0) { + const cacheMisses = [...cacheMissSet] + this.logger.trace(`Batch fetching ${cacheMisses.length} LID mappings from database`) + const stored = await this.keys.get('lid-mapping', cacheMisses) + + for (const pnUser of cacheMisses) { + const existingLidUser = stored[pnUser] if (existingLidUser) { - // Update cache with database value + existingMappings.set(pnUser, existingLidUser) this.mappingCache.set(`pn:${pnUser}`, existingLidUser) this.mappingCache.set(`lid:${existingLidUser}`, pnUser) } } + } + const pairMap: { [_: string]: string } = {} + for (const { pnUser, lidUser } of validatedPairs) { + const existingLidUser = existingMappings.get(pnUser) if (existingLidUser === lidUser) { this.logger.debug({ pnUser, lidUser }, 'LID mapping already exists, skipping') continue @@ -64,33 +84,55 @@ export class LIDMappingStore { pairMap[pnUser] = lidUser } + if (Object.keys(pairMap).length === 0) return + this.logger.trace({ pairMap }, `Storing ${Object.keys(pairMap).length} pn mappings`) - await this.keys.transaction(async () => { - for (const [pnUser, lidUser] of Object.entries(pairMap)) { - await this.keys.set({ - 'lid-mapping': { - [pnUser]: lidUser, - [`${lidUser}_reverse`]: pnUser - } - }) + const batchData: { [key: string]: string } = {} + for (const [pnUser, lidUser] of Object.entries(pairMap)) { + batchData[pnUser] = lidUser + batchData[`${lidUser}_reverse`] = pnUser + } - this.mappingCache.set(`pn:${pnUser}`, lidUser) - this.mappingCache.set(`lid:${lidUser}`, pnUser) - } + await this.keys.transaction(async () => { + await this.keys.set({ 'lid-mapping': batchData }) }, 'lid-mapping') + + // Update cache after successful DB write + for (const [pnUser, lidUser] of Object.entries(pairMap)) { + this.mappingCache.set(`pn:${pnUser}`, lidUser) + this.mappingCache.set(`lid:${lidUser}`, pnUser) + } } - /** - * Get LID for PN - Returns device-specific LID based on user mapping - */ async getLIDForPN(pn: string): Promise { return (await this.getLIDsForPNs([pn]))?.[0]?.lid || null } async getLIDsForPNs(pns: string[]): Promise { + if (pns.length === 0) return null + + const sortedPns = [...new Set(pns)].sort() + const cacheKey = sortedPns.join(',') + + const inflight = this.inflightLIDLookups.get(cacheKey) + if (inflight) { + this.logger.trace(`Coalescing getLIDsForPNs request for ${sortedPns.length} PNs`) + return inflight + } + + const promise = this._getLIDsForPNsImpl(pns) + this.inflightLIDLookups.set(cacheKey, promise) + + try { + return await promise + } finally { + this.inflightLIDLookups.delete(cacheKey) + } + } + + private async _getLIDsForPNsImpl(pns: string[]): Promise { const usyncFetch: { [_: string]: number[] } = {} - // mapped from pn to lid mapping to prevent duplication in results later const successfulPairs: { [_: string]: LIDMapping } = {} const pending: Array<{ pn: string; pnUser: string; decoded: ReturnType }> = [] @@ -118,7 +160,6 @@ export class LIDMappingStore { const decoded = jidDecode(pn) if (!decoded) continue - // Check cache first for PN → LID mapping const pnUser = decoded.user const cached = this.mappingCache.get(`pn:${pnUser}`) if (cached && typeof cached === 'string') { @@ -199,14 +240,33 @@ export class LIDMappingStore { return Object.values(successfulPairs).length > 0 ? Object.values(successfulPairs) : null } - /** - * Get PN for LID - USER LEVEL with device construction - */ async getPNForLID(lid: string): Promise { return (await this.getPNsForLIDs([lid]))?.[0]?.pn || null } async getPNsForLIDs(lids: string[]): Promise { + if (lids.length === 0) return null + + const sortedLids = [...new Set(lids)].sort() + const cacheKey = sortedLids.join(',') + + const inflight = this.inflightPNLookups.get(cacheKey) + if (inflight) { + this.logger.trace(`Coalescing getPNsForLIDs request for ${sortedLids.length} LIDs`) + return inflight + } + + const promise = this._getPNsForLIDsImpl(lids) + this.inflightPNLookups.set(cacheKey, promise) + + try { + return await promise + } finally { + this.inflightPNLookups.delete(cacheKey) + } + } + + private async _getPNsForLIDsImpl(lids: string[]): Promise { const successfulPairs: { [_: string]: LIDMapping } = {} const pending: Array<{ lid: string; lidUser: string; decoded: ReturnType }> = [] diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index efb38838..7737093a 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -478,6 +478,20 @@ export const makeChatsSocket = (config: SocketConfig) => { const resyncAppState = ev.createBufferedFunction( async (collections: readonly WAPatchName[], isInitialSync: boolean) => { + const appStateSyncKeyCache = new Map() + + const getCachedAppStateSyncKey = async ( + keyId: string + ): Promise => { + if (appStateSyncKeyCache.has(keyId)) { + return appStateSyncKeyCache.get(keyId) ?? undefined + } + + const key = await getAppStateSyncKey(keyId) + appStateSyncKeyCache.set(keyId, key ?? null) + return key + } + // we use this to determine which events to fire // otherwise when we resync from scratch -- all notifications will fire const initialVersionMap: { [T in WAPatchName]?: number } = {} @@ -547,7 +561,7 @@ export const makeChatsSocket = (config: SocketConfig) => { const { state: newState, mutationMap } = await decodeSyncdSnapshot( name, snapshot, - getAppStateSyncKey, + getCachedAppStateSyncKey, initialVersionMap[name], appStateMacVerification.snapshot ) @@ -565,7 +579,7 @@ export const makeChatsSocket = (config: SocketConfig) => { name, patches, states[name], - getAppStateSyncKey, + getCachedAppStateSyncKey, config.options, initialVersionMap[name], logger, diff --git a/src/Types/Events.ts b/src/Types/Events.ts index bf0d1d07..7fb2f337 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -27,6 +27,7 @@ export type BaileysEventMap = { chats: Chat[] contacts: Contact[] messages: WAMessage[] + lidPnMappings?: LIDMapping[] isLatest?: boolean progress?: number | null syncType?: proto.HistorySync.HistorySyncType | null diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index 2e8a0f40..f57e9127 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -287,14 +287,11 @@ const processMessage = async ( const data = await downloadAndProcessHistorySyncNotification(histNotification, options, logger) - // Emit LID-PN mappings from history sync - // This is how WhatsApp Web learns mappings for chats with non-contacts if (data.lidPnMappings?.length) { logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync') - // eslint-disable-next-line max-depth - for (const mapping of data.lidPnMappings) { - ev.emit('lid-mapping.update', mapping) - } + await signalRepository.lidMapping + .storeLIDPNMappings(data.lidPnMappings) + .catch(err => logger?.warn({ err }, 'failed to store LID-PN mappings from history sync')) } ev.emit('messaging-history.set', { From b5c174111f38039b511bff4a0c75d51aa03b4ebe 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 11:06:47 -0300 Subject: [PATCH 4/5] feat: replace async crypto with sync Rust WASM for app state sync (#2315) * feat: replace async crypto with sync Rust WASM for app state sync * fix: remove unecessary buffer copying * fix: update whatsapp-rust-bridge to version 0.5.2 and refactor async calls to sync. HKDF and MD5 in rust --- package.json | 1 + src/Socket/messages-recv.ts | 4 +- src/Socket/messages-send.ts | 4 +- src/Socket/socket.ts | 2 +- src/Types/Message.ts | 6 +- src/Utils/chat-utils.ts | 103 ++++++++++++++++++----------------- src/Utils/crypto.ts | 47 ++-------------- src/Utils/lt-hash.ts | 61 +-------------------- src/Utils/messages-media.ts | 10 ++-- src/Utils/noise-handler.ts | 28 +++++----- src/Utils/reporting-utils.ts | 4 +- yarn.lock | 8 +++ 12 files changed, 97 insertions(+), 181 deletions(-) diff --git a/package.json b/package.json index cda0ecaa..6b8fb2ee 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", + "whatsapp-rust-bridge": "0.5.2", "ws": "^8.13.0" }, "devDependencies": { diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 15c4c545..9bedbd26 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -818,7 +818,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { ) const random = randomBytes(32) const linkCodeSalt = randomBytes(32) - const linkCodePairingExpanded = await hkdf(companionSharedKey, 32, { + const linkCodePairingExpanded = hkdf(companionSharedKey, 32, { salt: linkCodeSalt, info: 'link_code_pairing_key_bundle_encryption_key' }) @@ -832,7 +832,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const encryptedPayload = Buffer.concat([linkCodeSalt, encryptIv, encrypted]) const identitySharedKey = Curve.sharedKey(authState.creds.signedIdentityKey.private, primaryIdentityPublicKey) const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random]) - authState.creds.advSecretKey = (await hkdf(identityPayload, 32, { info: 'adv_secret' })).toString('base64') + authState.creds.advSecretKey = Buffer.from(hkdf(identityPayload, 32, { info: 'adv_secret' })).toString('base64') await query({ tag: 'iq', attrs: { diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index b6177ede..4e90c459 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1159,7 +1159,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const content = assertMediaContent(message.message) const mediaKey = content.mediaKey! const meId = authState.creds.me!.id - const node = await encryptMediaRetryRequest(message.key, mediaKey, meId) + const node = encryptMediaRetryRequest(message.key, mediaKey, meId) let error: Error | undefined = undefined await Promise.all([ @@ -1171,7 +1171,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { error = result.error } else { try { - const media = await decryptMediaRetryData(result.media!, mediaKey, result.key.id!) + const media = decryptMediaRetryData(result.media!, mediaKey, result.key.id!) if (media.result !== proto.MediaRetryNotification.ResultType.SUCCESS) { const resultStr = proto.MediaRetryNotification.ResultType[media.result!] throw new Boom(`Media re-upload failed by device (${resultStr})`, { diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 87c65a5a..cd39f13a 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -429,7 +429,7 @@ export const makeSocket = (config: SocketConfig) => { logger.trace({ handshake }, 'handshake recv from WA') - const keyEnc = await noise.processHandshake(handshake, creds.noiseKey) + const keyEnc = noise.processHandshake(handshake, creds.noiseKey) let node: proto.IClientPayload if (!creds.me) { diff --git a/src/Types/Message.ts b/src/Types/Message.ts index dc8d7dfd..32e279e6 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -372,9 +372,9 @@ export type WAMessageCursor = { before: WAMessageKey | undefined } | { after: WA export type MessageUserReceiptUpdate = { key: WAMessageKey; receipt: MessageUserReceipt } export type MediaDecryptionKeyInfo = { - iv: Buffer - cipherKey: Buffer - macKey?: Buffer + iv: Uint8Array + cipherKey: Uint8Array + macKey?: Uint8Array } export type MinimalMessage = Pick diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index 0b8787de..daeb1a9a 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -1,4 +1,5 @@ import { Boom } from '@hapi/boom' +import { expandAppStateKeys } from 'whatsapp-rust-bridge' import { proto } from '../../WAProto/index.js' import type { BaileysEventEmitter, @@ -19,7 +20,7 @@ import { type MessageLabelAssociation } from '../Types/LabelAssociation' import { type BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidNormalizedUser } from '../WABinary' -import { aesDecrypt, aesEncrypt, hkdf, hmacSign } from './crypto' +import { aesDecrypt, aesEncrypt, hmacSign } from './crypto' import { toNumber } from './generics' import type { ILogger } from './logger' import { LT_HASH_ANTI_TAMPERING } from './lt-hash' @@ -30,47 +31,39 @@ type FetchAppStateSyncKey = (keyId: string) => Promise { - const expanded = await hkdf(keydata, 160, { info: 'WhatsApp Mutation Keys' }) +const mutationKeys = (keydata: Uint8Array) => { + const keys = expandAppStateKeys(keydata) return { - indexKey: expanded.slice(0, 32), - valueEncryptionKey: expanded.slice(32, 64), - valueMacKey: expanded.slice(64, 96), - snapshotMacKey: expanded.slice(96, 128), - patchMacKey: expanded.slice(128, 160) + indexKey: keys.indexKey, + valueEncryptionKey: keys.valueEncryptionKey, + valueMacKey: keys.valueMacKey, + snapshotMacKey: keys.snapshotMacKey, + patchMacKey: keys.patchMacKey } } const generateMac = ( operation: proto.SyncdMutation.SyncdOperation, - data: Buffer, + data: Uint8Array, keyId: Uint8Array | string, - key: Buffer + key: Uint8Array ) => { - const getKeyData = () => { - let r: number - switch (operation) { - case proto.SyncdMutation.SyncdOperation.SET: - r = 0x01 - break - case proto.SyncdMutation.SyncdOperation.REMOVE: - r = 0x02 - break - } + const opByte = operation === proto.SyncdMutation.SyncdOperation.SET ? 0x01 : 0x02 + const keyIdBuffer = typeof keyId === 'string' ? Buffer.from(keyId, 'base64') : keyId + const keyData = new Uint8Array(1 + keyIdBuffer.length) + keyData[0] = opByte + keyData.set(keyIdBuffer, 1) - const buff = Buffer.from([r]) - return Buffer.concat([buff, Buffer.from(keyId as string, 'base64')]) - } + const last = new Uint8Array(8) + last[7] = keyData.length - const keyData = getKeyData() + const total = new Uint8Array(keyData.length + data.length + last.length) + total.set(keyData, 0) + total.set(data, keyData.length) + total.set(last, keyData.length + data.length) - const last = Buffer.alloc(8) // 8 bytes - last.set([keyData.length], last.length - 1) - - const total = Buffer.concat([keyData, data, last]) const hmac = hmacSign(total, key, 'sha512') - - return hmac.slice(0, 32) + return hmac.subarray(0, 32) } const to64BitNetworkOrder = (e: number) => { @@ -83,8 +76,8 @@ type Mac = { indexMac: Uint8Array; valueMac: Uint8Array; operation: proto.SyncdM const makeLtHashGenerator = ({ indexValueMap, hash }: Pick) => { indexValueMap = { ...indexValueMap } - const addBuffs: ArrayBuffer[] = [] - const subBuffs: ArrayBuffer[] = [] + const addBuffs: Uint8Array[] = [] + const subBuffs: Uint8Array[] = [] return { mix: ({ indexMac, valueMac, operation }: Mac) => { @@ -98,29 +91,27 @@ const makeLtHashGenerator = ({ indexValueMap, hash }: Pick { - const hashArrayBuffer = new Uint8Array(hash).buffer - const result = await LT_HASH_ANTI_TAMPERING.subtractThenAdd(hashArrayBuffer, addBuffs, subBuffs) - const buffer = Buffer.from(result) + finish: () => { + const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(hash, subBuffs, addBuffs) return { - hash: buffer, + hash: Buffer.from(result), indexValueMap } } } } -const generateSnapshotMac = (lthash: Uint8Array, version: number, name: WAPatchName, key: Buffer) => { +const generateSnapshotMac = (lthash: Uint8Array, version: number, name: WAPatchName, key: Uint8Array) => { const total = Buffer.concat([lthash, to64BitNetworkOrder(version), Buffer.from(name, 'utf-8')]) return hmacSign(total, key, 'sha256') } @@ -130,7 +121,7 @@ const generatePatchMac = ( valueMacs: Uint8Array[], version: number, type: WAPatchName, - key: Buffer + key: Uint8Array ) => { const total = Buffer.concat([snapshotMac, ...valueMacs, to64BitNetworkOrder(version), Buffer.from(type, 'utf-8')]) return hmacSign(total, key) @@ -162,7 +153,7 @@ export const encodeSyncdPatch = async ( }) const encoded = proto.SyncActionData.encode(dataProto).finish() - const keyValue = await mutationKeys(key.keyData!) + const keyValue = mutationKeys(key.keyData!) const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey) const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey) @@ -171,7 +162,7 @@ export const encodeSyncdPatch = async ( // update LT hash const generator = makeLtHashGenerator(state) generator.mix({ indexMac, valueMac, operation }) - Object.assign(state, await generator.finish()) + Object.assign(state, generator.finish()) state.version += 1 @@ -211,6 +202,8 @@ export const decodeSyncdMutations = async ( validateMacs: boolean ) => { const ltGenerator = makeLtHashGenerator(initialState) + const derivedKeyCache = new Map>() + // indexKey used to HMAC sign record.index.blob // valueEncryptionKey used to AES-256-CBC encrypt record.value.blob[0:-32] // the remaining record.value.blob[0:-32] is the mac, it the HMAC sign of key.keyId + decoded proto data + length of bytes in keyId @@ -222,9 +215,9 @@ export const decodeSyncdMutations = async ( 'record' in msgMutation && !!msgMutation.record ? msgMutation.record : (msgMutation as proto.ISyncdRecord) const key = await getKey(record.keyId!.id!) - const content = Buffer.from(record.value!.blob!) - const encContent = content.slice(0, -32) - const ogValueMac = content.slice(-32) + const content = record.value!.blob! + const encContent = content.subarray(0, -32) + const ogValueMac = content.subarray(-32) if (validateMacs) { const contentHmac = generateMac(operation!, encContent, record.keyId!.id!, key.valueMacKey) if (Buffer.compare(contentHmac, ogValueMac) !== 0) { @@ -252,10 +245,16 @@ export const decodeSyncdMutations = async ( }) } - return await ltGenerator.finish() + return ltGenerator.finish() async function getKey(keyId: Uint8Array) { const base64Key = Buffer.from(keyId).toString('base64') + + const cached = derivedKeyCache.get(base64Key) + if (cached) { + return cached + } + const keyEnc = await getAppStateSyncKey(base64Key) if (!keyEnc) { throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { @@ -264,7 +263,9 @@ export const decodeSyncdMutations = async ( }) } - return mutationKeys(keyEnc.keyData!) + const keys = mutationKeys(keyEnc.keyData!) + derivedKeyCache.set(base64Key, keys) + return keys } } @@ -283,7 +284,7 @@ export const decodeSyncdPatch = async ( throw new Boom(`failed to find key "${base64Key}" to decode patch`, { statusCode: 404, data: { msg } }) } - const mainKey = await mutationKeys(mainKeyObj.keyData!) + const mainKey = mutationKeys(mainKeyObj.keyData!) const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32)) const patchMac = generatePatchMac( @@ -405,7 +406,7 @@ export const decodeSyncdSnapshot = async ( throw new Boom(`failed to find key "${base64Key}" to decode mutation`) } - const result = await mutationKeys(keyEnc.keyData!) + const result = mutationKeys(keyEnc.keyData!) const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey) if (Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) { throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`) @@ -473,7 +474,7 @@ export const decodePatches = async ( throw new Boom(`failed to find key "${base64Key}" to decode mutation`) } - const result = await mutationKeys(keyEnc.keyData!) + const result = mutationKeys(keyEnc.keyData!) const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey) if (Buffer.compare(snapshotMac!, computedSnapshotMac) !== 0) { throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`) diff --git a/src/Utils/crypto.ts b/src/Utils/crypto.ts index 0e0dc2a1..8bc87c30 100644 --- a/src/Utils/crypto.ts +++ b/src/Utils/crypto.ts @@ -2,6 +2,7 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } import * as curve from 'libsignal/src/curve' import { KEY_BUNDLE_TYPE } from '../Defaults' import type { KeyPair } from '../Types' +export { md5, hkdf } from 'whatsapp-rust-bridge' // insure browser & node compatibility const { subtle } = globalThis.crypto @@ -82,18 +83,18 @@ export function aesDecryptCTR(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8 } /** decrypt AES 256 CBC; where the IV is prefixed to the buffer */ -export function aesDecrypt(buffer: Buffer, key: Buffer) { - return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16)) +export function aesDecrypt(buffer: Uint8Array, key: Uint8Array) { + return aesDecryptWithIV(buffer.subarray(16), key, buffer.subarray(0, 16)) } /** decrypt AES 256 CBC */ -export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { +export function aesDecryptWithIV(buffer: Uint8Array, key: Uint8Array, IV: Uint8Array) { const aes = createDecipheriv('aes-256-cbc', key, IV) return Buffer.concat([aes.update(buffer), aes.final()]) } // encrypt AES 256 CBC; where a random IV is prefixed to the buffer -export function aesEncrypt(buffer: Buffer | Uint8Array, key: Buffer) { +export function aesEncrypt(buffer: Uint8Array, key: Uint8Array) { const IV = randomBytes(16) const aes = createCipheriv('aes-256-cbc', key, IV) return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer @@ -118,44 +119,6 @@ export function sha256(buffer: Buffer) { return createHash('sha256').update(buffer).digest() } -export function md5(buffer: Buffer) { - return createHash('md5').update(buffer).digest() -} - -// HKDF key expansion -export async function hkdf( - buffer: Uint8Array | Buffer, - expandedLength: number, - info: { salt?: Buffer; info?: string } -): Promise { - // Normalize to a Uint8Array whose underlying buffer is a regular ArrayBuffer (not ArrayBufferLike) - // Cloning via new Uint8Array(...) guarantees the generic parameter is ArrayBuffer which satisfies WebCrypto types. - const inputKeyMaterial = new Uint8Array(buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)) - - // Set default values if not provided - const salt = info.salt ? new Uint8Array(info.salt) : new Uint8Array(0) - const infoBytes = info.info ? new TextEncoder().encode(info.info) : new Uint8Array(0) - - // Import the input key material (cast to BufferSource to appease TS DOM typings) - const importedKey = await subtle.importKey('raw', inputKeyMaterial as BufferSource, { name: 'HKDF' }, false, [ - 'deriveBits' - ]) - - // Derive bits using HKDF - const derivedBits = await subtle.deriveBits( - { - name: 'HKDF', - hash: 'SHA-256', - salt: salt, - info: infoBytes - }, - importedKey, - expandedLength * 8 // Convert bytes to bits - ) - - return Buffer.from(derivedBits) -} - export async function derivePairingCodeKey(pairingCode: string, salt: Buffer): Promise { // Convert inputs to formats Web Crypto API can work with const encoder = new TextEncoder() diff --git a/src/Utils/lt-hash.ts b/src/Utils/lt-hash.ts index a673eba9..1ebd82b0 100644 --- a/src/Utils/lt-hash.ts +++ b/src/Utils/lt-hash.ts @@ -1,65 +1,8 @@ -import { hkdf } from './crypto' +import { LTHashAntiTampering } from 'whatsapp-rust-bridge' /** * LT Hash is a summation based hash algorithm that maintains the integrity of a piece of data * over a series of mutations. You can add/remove mutations and it'll return a hash equal to * if the same series of mutations was made sequentially. */ -const o = 128 - -class LTHash { - salt: string - - constructor(e: string) { - this.salt = e - } - - async add(e: ArrayBuffer, t: ArrayBuffer[]): Promise { - for (const item of t) { - e = await this._addSingle(e, item) - } - - return e - } - - async subtract(e: ArrayBuffer, t: ArrayBuffer[]): Promise { - for (const item of t) { - e = await this._subtractSingle(e, item) - } - - return e - } - - async subtractThenAdd(e: ArrayBuffer, addList: ArrayBuffer[], subtractList: ArrayBuffer[]): Promise { - const subtracted = await this.subtract(e, subtractList) - return this.add(subtracted, addList) - } - - private async _addSingle(e: ArrayBuffer, t: ArrayBuffer): Promise { - const derived = new Uint8Array(await hkdf(Buffer.from(t), o, { info: this.salt })).buffer - return this.performPointwiseWithOverflow(e, derived, (a, b) => a + b) - } - - private async _subtractSingle(e: ArrayBuffer, t: ArrayBuffer): Promise { - const derived = new Uint8Array(await hkdf(Buffer.from(t), o, { info: this.salt })).buffer - return this.performPointwiseWithOverflow(e, derived, (a, b) => a - b) - } - - private performPointwiseWithOverflow( - e: ArrayBuffer, - t: ArrayBuffer, - op: (a: number, b: number) => number - ): ArrayBuffer { - const n = new DataView(e) - const i = new DataView(t) - const out = new ArrayBuffer(n.byteLength) - const s = new DataView(out) - - for (let offset = 0; offset < n.byteLength; offset += 2) { - s.setUint16(offset, op(n.getUint16(offset, true), i.getUint16(offset, true)), true) - } - - return out - } -} -export const LT_HASH_ANTI_TAMPERING = new LTHash('WhatsApp Patch Integrity') +export const LT_HASH_ANTI_TAMPERING = new LTHashAntiTampering() diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index d2cbe7d3..8086242d 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -106,7 +106,7 @@ export async function getMediaKeys( } // expand using HKDF to 112 bytes, also pass in the relevant app info - const expandedMediaKey = await hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) }) + const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) }) return { iv: expandedMediaKey.slice(0, 16), cipherKey: expandedMediaKey.slice(16, 48), @@ -888,12 +888,12 @@ const getMediaRetryKey = (mediaKey: Buffer | Uint8Array) => { /** * Generate a binary node that will request the phone to re-upload the media & return the newly uploaded URL */ -export const encryptMediaRetryRequest = async (key: WAMessageKey, mediaKey: Buffer | Uint8Array, meId: string) => { +export const encryptMediaRetryRequest = (key: WAMessageKey, mediaKey: Buffer | Uint8Array, meId: string) => { const recp: proto.IServerErrorReceipt = { stanzaId: key.id } const recpBuffer = proto.ServerErrorReceipt.encode(recp).finish() const iv = Crypto.randomBytes(12) - const retryKey = await getMediaRetryKey(mediaKey) + const retryKey = getMediaRetryKey(mediaKey) const ciphertext = aesEncryptGCM(recpBuffer, retryKey, iv, Buffer.from(key.id!)) const req: BinaryNode = { @@ -963,12 +963,12 @@ export const decodeMediaRetryNode = (node: BinaryNode) => { return event } -export const decryptMediaRetryData = async ( +export const decryptMediaRetryData = ( { ciphertext, iv }: { ciphertext: Uint8Array; iv: Uint8Array }, mediaKey: Uint8Array, msgId: string ) => { - const retryKey = await getMediaRetryKey(mediaKey) + const retryKey = getMediaRetryKey(mediaKey) const plaintext = aesDecryptGCM(ciphertext, retryKey, iv, Buffer.from(msgId)) return proto.MediaRetryNotification.decode(plaintext) } diff --git a/src/Utils/noise-handler.ts b/src/Utils/noise-handler.ts index 02db94d3..76e1d712 100644 --- a/src/Utils/noise-handler.ts +++ b/src/Utils/noise-handler.ts @@ -24,8 +24,8 @@ class TransportState { private readonly iv = new Uint8Array(IV_LENGTH) constructor( - private readonly encKey: Buffer, - private readonly decKey: Buffer + private readonly encKey: Uint8Array, + private readonly decKey: Uint8Array ) {} encrypt(plaintext: Uint8Array): Uint8Array { @@ -64,9 +64,9 @@ export const makeNoiseHandler = ({ const data = Buffer.from(NOISE_MODE) let hash = data.byteLength === 32 ? data : sha256(data) - let salt: Buffer = hash - let encKey: Buffer = hash - let decKey: Buffer = hash + let salt: Uint8Array = hash + let encKey: Uint8Array = hash + let decKey: Uint8Array = hash let counter = 0 let sentIntro = false @@ -116,13 +116,13 @@ export const makeNoiseHandler = ({ return result } - const localHKDF = async (data: Uint8Array): Promise<[Buffer, Buffer]> => { - const key = await hkdf(Buffer.from(data), 64, { salt, info: '' }) + const localHKDF = (data: Uint8Array): [Uint8Array, Uint8Array] => { + const key = hkdf(Buffer.from(data), 64, { salt, info: '' }) return [key.subarray(0, 32), key.subarray(32)] } - const mixIntoKey = async (data: Uint8Array) => { - const [write, read] = await localHKDF(data) + const mixIntoKey = (data: Uint8Array) => { + const [write, read] = localHKDF(data) salt = write encKey = read decKey = read @@ -131,7 +131,7 @@ export const makeNoiseHandler = ({ const finishInit = async () => { isWaitingForTransport = true - const [write, read] = await localHKDF(new Uint8Array(0)) + const [write, read] = localHKDF(new Uint8Array(0)) transport = new TransportState(write, read) isWaitingForTransport = false @@ -179,12 +179,12 @@ export const makeNoiseHandler = ({ authenticate, mixIntoKey, finishInit, - processHandshake: async ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => { + processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => { authenticate(serverHello!.ephemeral!) - await mixIntoKey(Curve.sharedKey(privateKey, serverHello!.ephemeral!)) + mixIntoKey(Curve.sharedKey(privateKey, serverHello!.ephemeral!)) const decStaticContent = decrypt(serverHello!.static!) - await mixIntoKey(Curve.sharedKey(privateKey, decStaticContent)) + mixIntoKey(Curve.sharedKey(privateKey, decStaticContent)) const certDecoded = decrypt(serverHello!.payload!) @@ -223,7 +223,7 @@ export const makeNoiseHandler = ({ } const keyEnc = encrypt(noiseKey.public) - await mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello!.ephemeral!)) + mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello!.ephemeral!)) return keyEnc }, diff --git a/src/Utils/reporting-utils.ts b/src/Utils/reporting-utils.ts index 2c45ca56..6a839ca8 100644 --- a/src/Utils/reporting-utils.ts +++ b/src/Utils/reporting-utils.ts @@ -102,7 +102,7 @@ export const shouldIncludeReportingToken = (message: proto.IMessage): boolean => !message.encEventResponseMessage && !message.pollUpdateMessage -const generateMsgSecretKey = async ( +const generateMsgSecretKey = ( modificationType: string, origMsgId: string, origMsgSender: string, @@ -321,7 +321,7 @@ export const getMessageReportingToken = async ( const from = key.fromMe ? key.remoteJid! : key.participant || key.remoteJid! const to = key.fromMe ? key.participant || key.remoteJid! : key.remoteJid! - const reportingSecret = await generateMsgSecretKey(ENC_SECRET_REPORT_TOKEN, key.id, from, to, msgSecret) + const reportingSecret = generateMsgSecretKey(ENC_SECRET_REPORT_TOKEN, key.id, from, to, msgSecret) const content = extractReportingTokenContent(msgProtobuf, compiledReportingFields) if (!content || content.length === 0) { diff --git a/yarn.lock b/yarn.lock index 13b75cc1..504716e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3025,6 +3025,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" + whatsapp-rust-bridge: "npm:0.5.2" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10184,6 +10185,13 @@ __metadata: languageName: node linkType: hard +"whatsapp-rust-bridge@npm:0.5.2": + version: 0.5.2 + resolution: "whatsapp-rust-bridge@npm:0.5.2" + checksum: 10c0/022bff4659398144afe6834c279a9fc617a7cbc9177212874150ff2b39019044d2833af85d8ad8d5a40887ad84e7584edead15333e3acce58f989ef7cfd1e6f9 + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" 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 5/5] 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 */