Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions src/Socket/messages-recv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
})
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions src/Types/Socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ export type SocketConfig = {
options: AxiosRequestConfig<any>;
/**
* 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<proto.IMessage | undefined>;
getMessage: (
key: proto.IMessageKey,
reason?: GetMessageReason
) => Promise<proto.IMessage | undefined>;
};

export type GetMessageReason = "retry" | "bad-ack" | "secret";
168 changes: 163 additions & 5 deletions src/Utils/process-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<proto.Message.SecretEncryptedMessage.SecretEncType, string>>
> = {
[proto.Message.SecretEncryptedMessage.SecretEncType.MESSAGE_EDIT]:
"Message Edit",
[proto.Message.SecretEncryptedMessage.SecretEncType.EVENT_EDIT]: "Event Edit"
};

type ProcessMessageContext = {
shouldProcessHistoryMsg: boolean;
Expand All @@ -37,23 +53,36 @@ 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
? jidNormalizedUser(message.key.participant!)
: 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;
Expand All @@ -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
);
};
Expand Down
2 changes: 1 addition & 1 deletion src/Utils/reporting-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading