diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 59ce369e..6ecb2280 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -27,12 +27,16 @@ import { getNextPreKeys, getStatusFromReceiptType, hkdf, + normalizeMessageContent, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from "../Utils"; import { makeMutex } from "../Utils/make-mutex"; -import { cleanMessage } from "../Utils/process-message"; +import { + cleanMessage, + decryptSecretEncryptedMessage +} from "../Utils/process-message"; import { areJidsSameUser, BinaryNode, @@ -840,7 +844,49 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } } - cleanMessage(msg, authState.creds.me!.id); + cleanMessage(msg, authState.creds.me!.id, authState.creds.me?.lid); + + const content = normalizeMessageContent(msg.message); + const secretEncryptedMessage = content?.secretEncryptedMessage; + const secretEncType = secretEncryptedMessage?.secretEncType; + if ( + secretEncryptedMessage && + secretEncType !== null && + secretEncType !== undefined && + secretEncType !== + proto.Message.SecretEncryptedMessage.SecretEncType.UNKNOWN + ) { + const targetMessageKey = secretEncryptedMessage.targetMessageKey as + | WAMessageKey + | undefined; + const originalMessage = targetMessageKey?.id + ? await getMessage(targetMessageKey, "secret").catch(err => { + logger.warn( + { err, targetMessageKey }, + "failed to load original message for encrypted edit" + ); + return undefined; + }) + : undefined; + + const messageSecret = + normalizeMessageContent(originalMessage)?.messageContextInfo + ?.messageSecret; + if (messageSecret?.length) { + await decryptSecretEncryptedMessage( + msg, + messageSecret, + authState.creds.me!.id, + authState.creds.me?.lid, + logger + ); + } else { + logger.warn( + { targetMessageKey }, + "missing original message secret for encrypted edit" + ); + } + } await upsertMessage(msg, node.attrs.offline ? "append" : "notify"); }) @@ -950,7 +996,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const msg = ((await sentMessagesCache?.get(key.id!)) as | proto.IMessage - | undefined) || (await getMessage(key)); + | undefined) || (await getMessage(key, "bad-ack")); if (msg) { logger.trace( diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index e7e2696e..84c060d8 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -91,7 +91,13 @@ export type SocketConfig = { options: AxiosRequestConfig; /** * fetch a message from your store - * implement this so that messages failed to send (solves the "this message can take a while" issue) can be retried + * implement this so that messages failed to send (solves the "this message can take a while" issue) can be retried. + * `reason: "secret"` signals the caller only needs `messageContextInfo.messageSecret`, so the consumer can return a minimal shape. * */ - getMessage: (key: proto.IMessageKey) => Promise; + getMessage: ( + key: proto.IMessageKey, + reason?: GetMessageReason + ) => Promise; }; + +export type GetMessageReason = "retry" | "bad-ack" | "secret"; diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index ae0e950b..744c72b5 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -10,14 +10,30 @@ import { SignalKeyStoreWithTransaction, SocketConfig, WAMessage, + WAMessageKey, WAMessageStubType } from "../Types"; import { + aesDecryptGCM, downloadAndProcessHistorySyncNotification, normalizeMessageContent, toNumber } from "../Utils"; -import { areJidsSameUser, isJidGroup, jidNormalizedUser } from "../WABinary"; +import { + areJidsSameUser, + isJidGroup, + isLidUser, + jidNormalizedUser +} from "../WABinary"; +import { generateMsgSecretKey } from "./reporting-utils"; + +const SECRET_ENC_LABELS: Readonly< + Partial> +> = { + [proto.Message.SecretEncryptedMessage.SecretEncType.MESSAGE_EDIT]: + "Message Edit", + [proto.Message.SecretEncryptedMessage.SecretEncType.EVENT_EDIT]: "Event Edit" +}; type ProcessMessageContext = { shouldProcessHistoryMsg: boolean; @@ -37,7 +53,11 @@ const MSG_MISSED_CALL_TYPES = new Set([ ]); /** Cleans a received message to further processing */ -export const cleanMessage = (message: proto.IWebMessageInfo, meId: string) => { +export const cleanMessage = ( + message: proto.IWebMessageInfo, + meId: string, + meLid?: string +) => { // ensure remoteJid and participant doesn't have device or agent in it message.key.remoteJid = jidNormalizedUser(message.key.remoteJid!); message.key.participant = message.key.participant @@ -45,15 +65,24 @@ export const cleanMessage = (message: proto.IWebMessageInfo, meId: string) => { : undefined; const content = normalizeMessageContent(message.message); // if the message has a reaction, ensure fromMe & remoteJid are from our perspective - if (content?.reactionMessage) { - const msgKey = content.reactionMessage.key!; + if (content?.reactionMessage?.key) { + normaliseKey(content.reactionMessage.key); + } + + if (content?.secretEncryptedMessage?.targetMessageKey) { + normaliseKey(content.secretEncryptedMessage.targetMessageKey); + } + + function normaliseKey(msgKey: proto.IMessageKey) { // if the reaction is from another user // we've to correctly map the key to this user's perspective if (!message.key.fromMe) { // if the sender believed the message being reacted to is not from them // we've to correct the key to be from them, or some other participant msgKey.fromMe = !msgKey.fromMe - ? areJidsSameUser(msgKey.participant || msgKey.remoteJid!, meId) + ? areJidsSameUser(msgKey.participant || msgKey.remoteJid!, meId) || + (!!meLid && + areJidsSameUser(msgKey.participant || msgKey.remoteJid!, meLid)) : // if the message being reacted to, was from them // fromMe automatically becomes false false; @@ -65,12 +94,141 @@ export const cleanMessage = (message: proto.IWebMessageInfo, meId: string) => { } }; +export const decryptSecretEncryptedMessage = async ( + message: WAMessage, + messageSecret: Uint8Array, + meId: string, + meLid: string | undefined, + logger?: Logger +) => { + const content = normalizeMessageContent(message.message); + const secretEncryptedMessage = content?.secretEncryptedMessage; + if (!secretEncryptedMessage) return; + + const targetMessageKey = secretEncryptedMessage.targetMessageKey as + | WAMessageKey + | undefined; + const secretEncType = secretEncryptedMessage.secretEncType; + const { SecretEncType } = proto.Message.SecretEncryptedMessage; + + if ( + secretEncType === null || + secretEncType === undefined || + secretEncType === SecretEncType.UNKNOWN || + !targetMessageKey?.id + ) { + logger?.warn( + { + secretEncType, + targetMessageKey: secretEncryptedMessage.targetMessageKey + }, + "unsupported secret encrypted message type" + ); + return; + } + + const useCaseLabel = SECRET_ENC_LABELS[secretEncType]; + if (!useCaseLabel) { + logger?.info( + { secretEncType, targetMessageKey, messageKey: message.key }, + "no HKDF label registered for secret encrypted message type, leaving envelope intact" + ); + return; + } + + if ( + !secretEncryptedMessage.encPayload?.length || + !secretEncryptedMessage.encIv?.length + ) { + logger?.warn({ targetMessageKey }, "missing encrypted edit payload"); + return; + } + + const envelopeIsLid = + isLidUser(message.key.remoteJid ?? undefined) || + isLidUser(message.key.participant ?? undefined) || + isLidUser(targetMessageKey.remoteJid ?? undefined) || + isLidUser(targetMessageKey.participant ?? undefined); + const ownSender = jidNormalizedUser(envelopeIsLid && meLid ? meLid : meId); + const originalSender = targetMessageKey.fromMe + ? ownSender + : jidNormalizedUser( + targetMessageKey.participant || targetMessageKey.remoteJid || "" + ); + const modificationSender = message.key.fromMe + ? ownSender + : jidNormalizedUser(message.key.participant || message.key.remoteJid || ""); + + if (!originalSender || !modificationSender) { + logger?.warn( + { targetMessageKey, messageKey: message.key }, + "missing sender for secret encrypted message" + ); + return; + } + + let editedMessage: proto.IMessage | undefined; + try { + const decryptKey = await generateMsgSecretKey( + useCaseLabel, + targetMessageKey.id, + originalSender, + modificationSender, + messageSecret + ); + const decrypted = aesDecryptGCM( + secretEncryptedMessage.encPayload, + decryptKey, + secretEncryptedMessage.encIv, + Buffer.alloc(0) + ); + editedMessage = proto.Message.decode(decrypted); + } catch (err) { + logger?.warn( + { + err, + targetMessageKey, + messageKey: message.key, + originalSender, + modificationSender + }, + "failed to decrypt secret encrypted message" + ); + return; + } + + if ( + editedMessage.protocolMessage?.type === + proto.Message.ProtocolMessage.Type.MESSAGE_EDIT + ) { + message.message = editedMessage; + return; + } + + if ( + message.message?.messageContextInfo && + !editedMessage.messageContextInfo + ) { + editedMessage.messageContextInfo = message.message.messageContextInfo; + } + + message.message = { + protocolMessage: { + key: targetMessageKey, + type: proto.Message.ProtocolMessage.Type.MESSAGE_EDIT, + editedMessage, + timestampMs: toNumber(message.messageTimestamp) * 1000 + } + }; +}; + export const isRealMessage = (message: proto.IWebMessageInfo) => { const normalizedContent = normalizeMessageContent(message.message); return ( (!!normalizedContent || MSG_MISSED_CALL_TYPES.has(message.messageStubType!)) && !normalizedContent?.protocolMessage && + !normalizedContent?.secretEncryptedMessage && !normalizedContent?.reactionMessage ); }; diff --git a/src/Utils/reporting-utils.ts b/src/Utils/reporting-utils.ts index 11725f94..7e1ead15 100644 --- a/src/Utils/reporting-utils.ts +++ b/src/Utils/reporting-utils.ts @@ -344,7 +344,7 @@ const extractReportingTokenContent = ( return Buffer.concat(out.map(f => f.bytes)); }; -const generateMsgSecretKey = async ( +export const generateMsgSecretKey = async ( modificationType: string, origMsgId: string, origMsgSender: string,