diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 02d0a584091..546602a067a 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -48,6 +48,7 @@ import { NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, + setBotMessageSecret, toNumber, unixTimestampSeconds, xmppPreKey, @@ -1583,14 +1584,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } const handleMessage = async (node: BinaryNode) => { - const encNode = getBinaryNodeChild(node, 'enc') - // TODO: temporary fix for crashes and issues resulting of failed msmsg decryption - if (encNode?.attrs.type === 'msmsg') { - logger.debug({ key: node.attrs.key }, 'ignored msmsg') - await sendMessageAck(node, NACK_REASONS.MissingMessageSecret) - return - } - let acked = false try { diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index 39432e5eaac..4ce2d32fc54 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -18,6 +18,37 @@ import { } from '../WABinary' import { unpadRandomMax16 } from './generics' import type { ILogger } from './logger' +import { decodeDecryptedMsmsgMessage, decodeRichResponseMessage, decryptMsmsgBotMessage, type MsmsgMessageKey } from './meta-ai-msmsg' + +const MAX_SECRETS_PER_CHAT = 20 + +const botMessageSecrets = new Map() +const botRecentSecretsByChat = new Map() + +const pushRecentChatSecret = (chatJid: string, id: string, secretBuf: Buffer): void => { + if (!chatJid || !secretBuf) return + const existing = botRecentSecretsByChat.get(chatJid) || [] + const filtered = existing.filter(item => item.id !== id && !item.secret.equals(secretBuf)) + filtered.unshift({ id, secret: secretBuf }) + if (filtered.length > MAX_SECRETS_PER_CHAT) filtered.length = MAX_SECRETS_PER_CHAT + botRecentSecretsByChat.set(chatJid, filtered) +} + +export const setBotMessageSecret = (id: string, secret: Uint8Array | Buffer | string, chatJid?: string): void => { + if (!id || !secret) return + let buf: Buffer + if (Buffer.isBuffer(secret)) { + buf = secret + } else if (secret instanceof Uint8Array) { + buf = Buffer.from(secret.buffer, secret.byteOffset, secret.byteLength) + } else if (typeof secret === 'string') { + buf = Buffer.from(secret, 'base64') + } else { + return + } + botMessageSecrets.set(id, buf) + if (chatJid) pushRecentChatSecret(chatJid, id, buf) +} export const getDecryptionJid = async (sender: string, repository: SignalRepositoryWithLIDStore): Promise => { if (isLidUser(sender) || isHostedLidUser(sender)) { @@ -269,6 +300,12 @@ export const decryptMessageNode = ( logger: ILogger ) => { const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid) + + let metaTargetId: string | null = null + let botEditTargetId: string | null = null + let botType: string | null = null + let metaTargetSenderJid: string | null = null + return { fullMessage, category: stanza.attrs.category, @@ -276,6 +313,17 @@ export const decryptMessageNode = ( async decrypt() { let decryptables = 0 if (Array.isArray(stanza.content)) { + // Pre-scan for msmsg metadata nodes + const hasMsmsg = stanza.content.some(({ attrs }) => attrs?.type === 'msmsg') + if (hasMsmsg) { + for (const { tag, attrs } of stanza.content) { + if (tag === 'meta' && attrs?.target_id) metaTargetId = attrs.target_id + if (tag === 'meta' && attrs?.target_sender_jid) metaTargetSenderJid = attrs.target_sender_jid + if (tag === 'bot' && attrs && 'edit_target_id' in attrs) botEditTargetId = attrs.edit_target_id + if (tag === 'bot' && attrs?.edit) botType = attrs.edit + } + } + for (const { tag, attrs, content } of stanza.content) { if (tag === 'verified_name' && content instanceof Uint8Array) { const cert = proto.VerifiedNameCertificate.decode(content) @@ -301,7 +349,7 @@ export const decryptMessageNode = ( decryptables += 1 - let msgBuffer: Uint8Array + let msgBuffer: Uint8Array | Buffer | undefined const decryptionJid = await getDecryptionJid(author, repository) @@ -329,6 +377,79 @@ export const decryptMessageNode = ( ciphertext: content }) break + case 'msmsg': { + // 'first' = streaming partial — intentionally skip + if (botType !== null && !['full', 'last'].includes(botType)) break + + const secretIdCandidates = [botEditTargetId, metaTargetId, fullMessage.key?.id].filter(Boolean) as string[] + const secretCandidates: { source: string; secret: Buffer }[] = [] + const seenSecrets = new Set() + + for (const idCandidate of secretIdCandidates) { + const byId = botMessageSecrets.get(idCandidate) + if (!byId) continue + const fp = byId.toString('hex') + if (!seenSecrets.has(fp)) { + seenSecrets.add(fp) + secretCandidates.push({ source: `id:${idCandidate}`, secret: byId }) + } + } + + const chatRecent = botRecentSecretsByChat.get(sender) || [] + for (const item of chatRecent) { + const fp = item.secret.toString('hex') + if (!seenSecrets.has(fp)) { + seenSecrets.add(fp) + secretCandidates.push({ source: `chat:${item.id}`, secret: item.secret }) + } + if (secretCandidates.length >= 6) break + } + + if (!secretCandidates.length) { + logger.warn({ metaTargetId, botType, secretIdCandidates }, 'msmsg: no candidate messageSecret found, skipping') + break + } + + const msMsg = proto.MessageSecretMessage.decode(content) + const helperKey: MsmsgMessageKey = { + participant: author, + meId: metaTargetSenderJid || `${meLid.split(':')[0]}@lid`, + meLid, + conversationJid: sender, + senderJid: metaTargetSenderJid || undefined, + botType, + botEditTargetId, + metaTargetId, + stanzaId: stanza.attrs?.id, + targetId: botEditTargetId || metaTargetId || stanza.attrs?.id, + targetIdCandidates: secretIdCandidates + } + + let decryptErr: unknown + const candidateAttemptSummaries: object[] = [] + + for (const candidate of secretCandidates) { + try { + msgBuffer = await decryptMsmsgBotMessage(candidate.secret, helperKey, msMsg) + logger.debug({ source: candidate.source }, 'msmsg: decrypted with candidate secret') + break + } catch (e: any) { + decryptErr = e + if (Array.isArray(e?.attemptedStrategies) && e.attemptedStrategies.length) { + candidateAttemptSummaries.push({ secretSource: candidate.source, attemptedStrategies: e.attemptedStrategies }) + } + } + } + + if (!msgBuffer && candidateAttemptSummaries.length) { + logger.warn( + { secretCandidateSources: secretCandidates.map(c => c.source), attemptsBySecret: candidateAttemptSummaries }, + 'msmsg: helper decryption failed for all candidate secrets' + ) + } + if (!msgBuffer && decryptErr) throw decryptErr + break + } case 'plaintext': msgBuffer = content break @@ -336,10 +457,20 @@ export const decryptMessageNode = ( throw new Error(`Unknown e2e type: ${e2eType}`) } - let msg: proto.IMessage = proto.Message.decode( - e2eType !== 'plaintext' ? unpadRandomMax16(msgBuffer) : msgBuffer - ) + if (!msgBuffer) continue + + let msg: proto.IMessage = + e2eType === 'msmsg' + ? decodeDecryptedMsmsgMessage(msgBuffer) + : proto.Message.decode(e2eType !== 'plaintext' ? unpadRandomMax16(msgBuffer) : msgBuffer) + + const outerMessageContextInfo = msg.messageContextInfo msg = msg.deviceSentMessage?.message || msg + // deviceSentMessage.message may not carry messageContextInfo — preserve it + if (outerMessageContextInfo && !msg.messageContextInfo) { + msg.messageContextInfo = outerMessageContextInfo + } + if (msg.senderKeyDistributionMessage) { //eslint-disable-next-line max-depth try { @@ -357,6 +488,27 @@ export const decryptMessageNode = ( } else { fullMessage.message = msg } + + // Auto-decode richResponseMessage text (stored as dynamic property — not in proto schema) + const rich = fullMessage.message?.richResponseMessage as any + if (rich && !rich.text) { + const decoded = decodeRichResponseMessage(rich) + if (decoded) rich.text = decoded + } + const editedRich = fullMessage.message?.protocolMessage?.editedMessage?.richResponseMessage as any + if (editedRich && !editedRich.text) { + const decoded = decodeRichResponseMessage(editedRich) + if (decoded) editedRich.text = decoded + } + + // Cache messageSecret for future msmsg decryption + const secret = msg.messageContextInfo?.messageSecret + if (secret) { + const secretBuf = Buffer.isBuffer(secret) + ? secret + : Buffer.from((secret as Uint8Array).buffer, (secret as Uint8Array).byteOffset, (secret as Uint8Array).byteLength) + setBotMessageSecret(fullMessage.key.id!, secretBuf, fullMessage.key.remoteJid!) + } } catch (err: any) { const errorContext = { key: fullMessage.key, diff --git a/src/Utils/meta-ai-msmsg.ts b/src/Utils/meta-ai-msmsg.ts new file mode 100644 index 00000000000..15becb3261c --- /dev/null +++ b/src/Utils/meta-ai-msmsg.ts @@ -0,0 +1,291 @@ +import { proto } from '../../WAProto/index.js' +import { aesDecryptGCM, hkdf } from './crypto' +import { unpadRandomMax16 } from './generics' + +const BOT_MESSAGE_INFO = 'Bot Message' +const KEY_LENGTH = 32 +const AUTH_TAG_LENGTH = 16 + +const MSG_ID_HEX_RE = /^[0-9A-Fa-f]{32}$/ + +export interface MsmsgMessageKey { + participant: string + meId: string + meLid?: string + conversationJid?: string + senderJid?: string + botType?: string | null + botEditTargetId?: string | null + metaTargetId?: string | null + stanzaId?: string + targetId?: string + targetIdCandidates?: string[] +} + +interface IdCandidate { + messageId: string + idSource: string + idSources: string[] +} + +interface JidCandidate { + source: string + jid: string + value: Buffer +} + +interface DecryptionStrategy { + mode: '2step' + idSource: string + idSources: string[] + infoSource: string + aadSource: string + authTagLayout: 'trailing' + messageId: string + info: Buffer + aad: Buffer + attemptLabel: string +} + +const toBuffer = (value: Uint8Array | Buffer | string): Buffer => { + if (Buffer.isBuffer(value)) return value + if (value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.byteLength) + return Buffer.from(value) +} + +const normalizeLidJid = (jid: string | undefined): string | undefined => { + if (!jid || !jid.endsWith('@lid') || !jid.includes(':')) return jid + return `${jid.split(':')[0]}@lid` +} + +const buildMessageIdRepresentations = (messageId: string): { label: string; value: Buffer }[] => { + const ascii = Buffer.from(messageId) + const binary = MSG_ID_HEX_RE.test(messageId) ? Buffer.from(messageId, 'hex') : ascii + return [ + { label: 'msgIdAscii', value: ascii }, + ...(binary.equals(ascii) ? [] : [{ label: 'msgIdBinary', value: binary }]) + ] +} + +const pushUnique = (items: DecryptionStrategy[], seen: Set, item: DecryptionStrategy): void => { + const key = JSON.stringify([ + item.messageId, + item.idSource, + item.idSources, + item.infoSource, + item.aadSource, + item.info.toString('hex'), + item.aad.toString('hex') + ]) + if (!seen.has(key)) { + seen.add(key) + items.push(item) + } +} + +const getCandidateIds = (messageKey: MsmsgMessageKey): IdCandidate[] => { + const orderedCandidates: { source: string; messageId: string | null | undefined }[] = [ + messageKey.botType === 'full' + ? { source: 'stanzaId', messageId: messageKey.stanzaId } + : { source: 'botEditTargetId', messageId: messageKey.botEditTargetId }, + { source: 'targetId', messageId: messageKey.targetId }, + { source: 'metaTargetId', messageId: messageKey.metaTargetId }, + { source: 'stanzaId', messageId: messageKey.stanzaId } + ] + + const targetIdCandidates = Array.isArray(messageKey.targetIdCandidates) ? messageKey.targetIdCandidates : [] + for (let index = 0; index < targetIdCandidates.length; index++) { + orderedCandidates.push({ source: `targetIdCandidates[${index}]`, messageId: targetIdCandidates[index] }) + } + + const grouped = new Map() + for (const candidate of orderedCandidates) { + if (!candidate.messageId) continue + const messageId = String(candidate.messageId) + const existing = grouped.get(messageId) + if (existing) { + if (!existing.idSources.includes(candidate.source)) { + existing.idSources.push(candidate.source) + } + } else { + grouped.set(messageId, { messageId, idSource: candidate.source, idSources: [candidate.source] }) + } + } + + return Array.from(grouped.values()) +} + +const getJidCandidates = (messageKey: MsmsgMessageKey): JidCandidate[] => { + const ordered: { source: string; jid: string | undefined }[] = [ + { source: 'meId', jid: messageKey.meId }, + { source: 'conversationJid', jid: messageKey.conversationJid }, + { source: 'senderJid', jid: messageKey.senderJid }, + { source: 'meLidNormalized', jid: normalizeLidJid(messageKey.meLid) } + ] + + const seen = new Set() + const candidates: JidCandidate[] = [] + for (const candidate of ordered) { + if (!candidate.jid) continue + const jid = String(candidate.jid) + if (!seen.has(jid)) { + seen.add(jid) + candidates.push({ source: candidate.source, jid, value: Buffer.from(jid) }) + } + } + + return candidates +} + +export const buildMsmsgDecryptionStrategies = (messageKey: MsmsgMessageKey): DecryptionStrategy[] => { + const botJid = String(messageKey.participant || '') + const botJidBuffer = Buffer.from(botJid) + const targetIds = getCandidateIds(messageKey) + const jidCandidates = getJidCandidates(messageKey) + const primaryJid = jidCandidates[0] + if (!primaryJid) return [] + const alternateJid = jidCandidates.find( + candidate => candidate.source !== primaryJid.source && candidate.jid !== botJid + ) + const strategies: DecryptionStrategy[] = [] + const seen = new Set() + + for (const idCandidate of targetIds) { + const idForms = buildMessageIdRepresentations(idCandidate.messageId) + for (const idForm of idForms) { + pushUnique(strategies, seen, { + mode: '2step', + idSource: idCandidate.idSource, + idSources: idCandidate.idSources, + infoSource: `${idForm.label}+meId+botJid`, + aadSource: `${idForm.label}+0+botJid`, + authTagLayout: 'trailing', + messageId: idCandidate.messageId, + info: Buffer.concat([idForm.value, primaryJid.value, botJidBuffer, new Uint8Array(0)]), + aad: Buffer.concat([idForm.value, Buffer.from([0]), botJidBuffer]), + attemptLabel: `${idCandidate.idSource}:${idForm.label}:primary` + }) + + if (alternateJid) { + pushUnique(strategies, seen, { + mode: '2step', + idSource: idCandidate.idSource, + idSources: idCandidate.idSources, + infoSource: `${idForm.label}+${alternateJid.source}+botJid`, + aadSource: `${idForm.label}+0+${alternateJid.source}`, + authTagLayout: 'trailing', + messageId: idCandidate.messageId, + info: Buffer.concat([idForm.value, alternateJid.value, botJidBuffer, new Uint8Array(0)]), + aad: Buffer.concat([idForm.value, Buffer.from([0]), alternateJid.value]), + attemptLabel: `${idCandidate.idSource}:${idForm.label}:${alternateJid.source}` + }) + } + } + } + + return strategies.slice(0, 12) +} + +const assertRequired = (value: unknown, label: string): void => { + if ( + !value || + (value instanceof Uint8Array && value.byteLength === 0) + ) { + throw new Error(`Missing required ${label} for msmsg decryption`) + } +} + +const decryptWithStrategy = ( + messageSecret: Uint8Array | Buffer, + msMsg: proto.IMessageSecretMessage, + strategy: DecryptionStrategy +): Buffer => { + const baseSecret = Buffer.from(hkdf(toBuffer(messageSecret), KEY_LENGTH, { info: BOT_MESSAGE_INFO })) + const key = Buffer.from(hkdf(baseSecret, KEY_LENGTH, { info: strategy.info })) + const payload = toBuffer(msMsg.encPayload as Uint8Array) + const ciphertextWithTag = Buffer.concat([payload.slice(0, -AUTH_TAG_LENGTH), payload.slice(-AUTH_TAG_LENGTH)]) + return aesDecryptGCM(ciphertextWithTag, key, toBuffer(msMsg.encIv as Uint8Array), strategy.aad) +} + +export const decodeDecryptedMsmsgMessage = (decrypted: Uint8Array | Buffer): proto.IMessage => { + const messageBuffer = toBuffer(decrypted) + try { + const unpadded = Buffer.from(unpadRandomMax16(messageBuffer)) + const decoded = proto.Message.decode(unpadded) + const hasContent = Object.keys(decoded).some(key => key !== 'messageContextInfo' && decoded[key as keyof typeof decoded] != null) + if (hasContent) return decoded + } catch { + // fall through to unpadded decode below + } + return proto.Message.decode(messageBuffer) +} + +export const decryptMsmsgBotMessage = async ( + messageSecret: Uint8Array | Buffer, + messageKey: MsmsgMessageKey, + msMsg: proto.IMessageSecretMessage +): Promise => { + assertRequired(messageSecret, 'messageSecret') + assertRequired(messageKey.participant, 'participant') + assertRequired(messageKey.meId, 'meId') + assertRequired(msMsg.encIv, 'encIv') + assertRequired(msMsg.encPayload, 'encPayload') + + if (getCandidateIds(messageKey).length === 0) { + throw new Error('Missing required target message id for msmsg decryption') + } + + const strategies = buildMsmsgDecryptionStrategies(messageKey) + const attemptedStrategies: object[] = [] + let lastError: unknown + + for (const strategy of strategies) { + const attempt = { + idSource: strategy.idSource, + idSources: strategy.idSources, + infoSource: strategy.infoSource, + aadSource: strategy.aadSource, + messageId: strategy.messageId + } + try { + return decryptWithStrategy(messageSecret, msMsg, strategy) + } catch (error) { + attemptedStrategies.push(attempt) + lastError = error + } + } + + const error = Object.assign(new Error('Failed to decrypt msmsg with bounded deterministic strategies'), { + attemptedStrategies, + cause: lastError + }) + throw error +} + +export const decodeRichResponseMessage = (richMsg: proto.IAIRichResponseMessage | null | undefined): string => { + try { + if (!richMsg) return '' + if (Array.isArray(richMsg.submessages) && richMsg.submessages.length > 0) { + const sub = richMsg.submessages + .map((s: proto.IAIRichResponseSubMessage) => s.messageText) + .filter(Boolean) + .join('\n') + if (sub) return sub + } + const data = (richMsg as any).unifiedResponse?.data + if (!data) return '' + const json = JSON.parse(Buffer.from(data, 'base64').toString('utf8')) + const texts: string[] = [] + for (const section of json.sections || []) { + const prim = section?.view_model?.primitive + if (prim?.text) texts.push(prim.text) + if (prim?.header) texts.push(prim.header) + for (const sub of section?.view_model?.items || []) { + if (sub?.primitive?.text) texts.push(sub.primitive.text) + } + } + return texts.join('\n') + } catch { + return '' + } +}