From fdfe310fdfab291386e95d4d6ec66009e27df95b Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Tue, 28 Sep 2021 19:22:39 +0530 Subject: [PATCH] implement encrypting app patches --- src/Socket/chats.ts | 154 ++++++++++------- src/Socket/groups.ts | 69 ++++---- src/Socket/index.ts | 2 +- src/Socket/messages-recv.ts | 55 ++++-- src/Socket/messages-send.ts | 21 ++- src/Types/Auth.ts | 9 +- src/Types/Chat.ts | 4 +- src/Types/Message.ts | 8 + src/Utils/chat-utils.ts | 285 ++++++++++++++++++++++--------- src/Utils/history.ts | 6 - src/Utils/validate-connection.ts | 19 ++- src/WABinary/LTHash.ts | 4 +- 12 files changed, 420 insertions(+), 216 deletions(-) diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index 059d61a..40ae97f 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -1,18 +1,20 @@ -import { decodeSyncdPatch, encodeSyncdPatch } from "../Utils/chat-utils"; -import { SocketConfig, WAPresence, PresenceData, Chat, ChatModification, WAMediaUpload, ChatMutation } from "../Types"; +import { encodeSyncdPatch, decodePatches, extractSyncdPatches } from "../Utils/chat-utils"; +import { SocketConfig, WAPresence, PresenceData, Chat, ChatModification, WAMediaUpload, ChatMutation, WAPatchName, LTHashState } from "../Types"; import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidNormalizedUser, S_WHATSAPP_NET } from "../WABinary"; -import { makeSocket } from "./socket"; import { proto } from '../../WAProto' -import { toNumber } from "../Utils/generics"; -import { generateProfilePicture } from "../Utils"; +import { generateProfilePicture, toNumber } from "../Utils"; +import { randomBytes } from "crypto"; +import { makeMessagesRecvSocket } from "./messages-recv"; export const makeChatsSocket = (config: SocketConfig) => { const { logger } = config - const sock = makeSocket(config) + const sock = makeMessagesRecvSocket(config) const { ev, ws, authState, + processMessage, + relayMessage, generateMessageTag, sendNode, query @@ -25,7 +27,6 @@ export const makeChatsSocket = (config: SocketConfig) => { to: S_WHATSAPP_NET, type: 'get', xmlns: 'usync', - }, content: [ { @@ -188,30 +189,38 @@ export const makeChatsSocket = (config: SocketConfig) => { }) } - const collectionSync = async() => { - const COLLECTIONS = ['critical_block', 'critical_unblock_low', 'regular_low', 'regular_high'] - await sendNode({ + const collectionSync = async(collections: { name: WAPatchName, version: number }[]) => { + const result = await query({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, xmlns: 'w:sync:app:state', - type: 'set', - id: generateMessageTag(), + type: 'set' }, content: [ { tag: 'sync', attrs: { }, - content: COLLECTIONS.map( - name => ({ + content: collections.map( + ({ name, version }) => ({ tag: 'collection', - attrs: { name, version: '0', return_snapshot: 'true' } + attrs: { name, version: version.toString(), return_snapshot: 'true' } }) ) } ] }) - logger.info('synced collection') + const syncNode = getBinaryNodeChild(result, 'sync') + const collectionNodes = getBinaryNodeChildren(syncNode, 'collection') + return collectionNodes.reduce( + (dict, node) => { + const snapshotNode = getBinaryNodeChild(node, 'snapshot') + if(snapshotNode) { + dict[node.attrs.name] = snapshotNode.content as Uint8Array + } + return dict + }, { } as { [P in WAPatchName]: Uint8Array } + ) } const profilePictureUrl = async(jid: string) => { @@ -311,18 +320,31 @@ export const makeChatsSocket = (config: SocketConfig) => { } else { logger.warn({ action, id }, 'unprocessable update') } - updates.push(update) + if(Object.keys(update).length > 1) { + updates.push(update) + } } ev.emit('chats.update', updates) } - const patchChat = async( - jid: string, - modification: ChatModification + const appPatch = async( + syncAction: proto.ISyncActionValue, + index: [string, string], + name: WAPatchName, + operation: proto.SyncdMutation.SyncdMutationSyncdOperation.SET, ) => { - const patch = await encodeSyncdPatch(modification, { remoteJid: jid }, authState) - const type = 'regular_high' - const ver = authState.creds.appStateVersion![type] || 0 + await resyncState(name, false) + const { patch, state } = await encodeSyncdPatch( + syncAction, + index, + name, + operation, + authState, + ) + const initial = await authState.keys.getAppStateSyncVersion(name) + // temp: verify it was encoded correctly + const result = await decodePatches({ syncds: [{ ...patch, version: { version: state.version }, }], name }, initial, authState) + const node: BinaryNode = { tag: 'iq', attrs: { @@ -332,29 +354,35 @@ export const makeChatsSocket = (config: SocketConfig) => { }, content: [ { - tag: 'patch', - attrs: { - name: type, - version: (ver+1).toString(), - return_snapshot: 'false' - }, - content: proto.SyncdPatch.encode(patch).finish() + tag: 'sync', + attrs: { }, + content: [ + { + tag: 'collection', + attrs: { + name, + version: (state.version-1).toString(), + return_snapshot: 'false' + }, + content: [ + { + tag: 'patch', + attrs: { }, + content: proto.SyncdPatch.encode(patch).finish() + } + ] + } + ] } ] } await query(node) - authState.creds.appStateVersion![type] += 1 + await authState.keys.setAppStateSyncVersion(name, state) ev.emit('auth-state.update', authState) } - const resyncState = async(name: 'regular_high' | 'regular_low' = 'regular_high') => { - authState.creds.appStateVersion = authState.creds.appStateVersion || { - regular_high: 0, - regular_low: 0, - critical_unblock_low: 0, - critical_block: 0 - } + const fetchAppState = async(name: WAPatchName, fromVersion: number) => { const result = await query({ tag: 'iq', attrs: { @@ -371,7 +399,7 @@ export const makeChatsSocket = (config: SocketConfig) => { tag: 'collection', attrs: { name, - version: authState.creds.appStateVersion[name].toString(), + version: fromVersion.toString(), return_snapshot: 'false' } } @@ -379,30 +407,24 @@ export const makeChatsSocket = (config: SocketConfig) => { } ] }) - const syncNode = getBinaryNodeChild(result, 'sync') - const collectionNode = getBinaryNodeChild(syncNode, 'collection') - const patchesNode = getBinaryNodeChild(collectionNode, 'patches') + return result + } + + const resyncState = async(name: WAPatchName, fromScratch: boolean) => { + let state: LTHashState = fromScratch ? undefined : await authState.keys.getAppStateSyncVersion(name) + if(!state) state = { version: 0, hash: Buffer.alloc(128), mutations: [] } + + logger.info(`resyncing ${name} from v${state.version}`) + + const result = await fetchAppState(name, state.version) + const decoded = extractSyncdPatches(result) // extract from binary node + const { newMutations, state: newState } = await decodePatches(decoded, state, authState, true) + + await authState.keys.setAppStateSyncVersion(name, newState) + + logger.info(`synced ${name} to v${newState.version}`) + processSyncActions(newMutations) - const patches = getBinaryNodeChildren(patchesNode, 'patch') - const successfulMutations: ChatMutation[] = [] - for(const { content } of patches) { - if(content) { - const syncd = proto.SyncdPatch.decode(content! as Uint8Array) - const version = toNumber(syncd.version!.version!) - if(version) { - authState.creds.appStateVersion[name] = Math.max(version, authState.creds.appStateVersion[name]) - } - const { mutations, failures } = await decodeSyncdPatch(syncd, authState) - if(failures.length) { - logger.info( - { failures: failures.map(f => ({ trace: f.stack, data: f.data })) }, - 'failed to decode' - ) - } - successfulMutations.push(...mutations) - } - } - processSyncActions(successfulMutations) ev.emit('auth-state.update', authState) } @@ -412,7 +434,7 @@ export const makeChatsSocket = (config: SocketConfig) => { ws.on('CB:notification,type:server_sync', (node: BinaryNode) => { const update = getBinaryNodeChild(node, 'collection') if(update) { - resyncState(update.attrs.name as any) + resyncState(update.attrs.name as WAPatchName, false) } }) @@ -421,13 +443,12 @@ export const makeChatsSocket = (config: SocketConfig) => { sendPresenceUpdate('available') fetchBlocklist() fetchPrivacySettings() - //collectionSync() } }) return { ...sock, - patchChat, + appPatch, sendPresenceUpdate, presenceSubscribe, profilePictureUrl, @@ -436,6 +457,7 @@ export const makeChatsSocket = (config: SocketConfig) => { fetchPrivacySettings, fetchStatus, updateProfilePicture, - updateBlockStatus + updateBlockStatus, + resyncState, } } \ No newline at end of file diff --git a/src/Socket/groups.ts b/src/Socket/groups.ts index f157684..39468b6 100644 --- a/src/Socket/groups.ts +++ b/src/Socket/groups.ts @@ -1,41 +1,10 @@ import { generateMessageID } from "../Utils"; import { SocketConfig, GroupMetadata, ParticipantAction } from "../Types"; -import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidDecode, jidEncode } from "../WABinary"; -import { makeChatsSocket } from "./chats"; - -const extractGroupMetadata = (result: BinaryNode) => { - const group = getBinaryNodeChild(result, 'group') - const descChild = getBinaryNodeChild(group, 'description') - let desc: string | undefined - let descId: string | undefined - if(descChild) { - desc = getBinaryNodeChild(descChild, 'body')?.content as string - descId = descChild.attrs.id - } - const groupId = group.attrs.id.includes('@') ? group.attrs.id : jidEncode(group.attrs.id, 'g.us') - const metadata: GroupMetadata = { - id: groupId, - subject: group.attrs.subject, - creation: +group.attrs.creation, - owner: group.attrs.creator, - desc, - descId, - restrict: !!getBinaryNodeChild(result, 'locked'), - announce: !!getBinaryNodeChild(result, 'announcement'), - participants: getBinaryNodeChildren(group, 'participant').map( - ({ attrs }) => { - return { - id: attrs.jid, - admin: attrs.type || null as any, - } - } - ) - } - return metadata -} +import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidEncode } from "../WABinary"; +import { makeSocket } from "./socket"; export const makeGroupsSocket = (config: SocketConfig) => { - const sock = makeChatsSocket(config) + const sock = makeSocket(config) const { query } = sock const groupQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => ( @@ -146,4 +115,36 @@ export const makeGroupsSocket = (config: SocketConfig) => { await groupQuery(jid, 'set', [ { tag: setting, attrs: { } } ]) } } +} + + +const extractGroupMetadata = (result: BinaryNode) => { + const group = getBinaryNodeChild(result, 'group') + const descChild = getBinaryNodeChild(group, 'description') + let desc: string | undefined + let descId: string | undefined + if(descChild) { + desc = getBinaryNodeChild(descChild, 'body')?.content as string + descId = descChild.attrs.id + } + const groupId = group.attrs.id.includes('@') ? group.attrs.id : jidEncode(group.attrs.id, 'g.us') + const metadata: GroupMetadata = { + id: groupId, + subject: group.attrs.subject, + creation: +group.attrs.creation, + owner: group.attrs.creator, + desc, + descId, + restrict: !!getBinaryNodeChild(result, 'locked'), + announce: !!getBinaryNodeChild(result, 'announcement'), + participants: getBinaryNodeChildren(group, 'participant').map( + ({ attrs }) => { + return { + id: attrs.jid, + admin: attrs.type || null as any, + } + } + ) + } + return metadata } \ No newline at end of file diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 0ffddfe..f949186 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -1,6 +1,6 @@ import { SocketConfig } from '../Types' import { DEFAULT_CONNECTION_CONFIG } from '../Defaults' -import { makeMessagesSocket as _makeSocket } from './messages-send' +import { makeChatsSocket as _makeSocket } from './chats' // export the last socket layer const makeWASocket = (config: Partial) => ( diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index e651ae1..997605f 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -1,22 +1,23 @@ -import { makeGroupsSocket } from "./groups" import { SocketConfig, WAMessageStubType, ParticipantAction, Chat, GroupMetadata } from "../Types" import { decodeMessageStanza, encodeBigEndian, toNumber } from "../Utils" import { BinaryNode, jidDecode, jidEncode, isJidStatusBroadcast, areJidsSameUser, getBinaryNodeChildren, jidNormalizedUser } from '../WABinary' -import { downloadIfHistory } from '../Utils/history' +import { downloadHistory } from '../Utils/history' import { proto } from "../../WAProto" import { generateSignalPubKey, xmppPreKey, xmppSignedPreKey } from "../Utils/signal" import { KEY_BUNDLE_TYPE } from "../Defaults" +import { makeMessagesSocket } from "./messages-send" export const makeMessagesRecvSocket = (config: SocketConfig) => { const { logger } = config - const sock = makeGroupsSocket(config) + const sock = makeMessagesSocket(config) const { ev, authState, ws, assertingPreKeys, sendNode, + relayMessage, } = sock const sendMessageAck = async({ attrs }: BinaryNode) => { @@ -103,11 +104,45 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const protocolMsg = message.message?.protocolMessage if(protocolMsg) { switch(protocolMsg.type) { + case proto.ProtocolMessage.ProtocolMessageType.HISTORY_SYNC_NOTIFICATION: + const history = await downloadHistory(protocolMsg!.historySyncNotification) + processHistoryMessage(history) + break + case proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_REQUEST: + const keys = await Promise.all( + protocolMsg.appStateSyncKeyRequest!.keyIds!.map( + async id => { + const keyId = Buffer.from(id.keyId!).toString('base64') + const keyData = await authState.keys.getAppStateSyncKey(keyId) + logger.info({ keyId }, 'received key request') + return { + keyId: id, + keyData + } + } + ) + ) + + const msg: proto.IMessage = { + protocolMessage: { + type: proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_SHARE, + appStateSyncKeyShare: { + keys + } + } + } + await relayMessage(message.key.remoteJid!, msg, { }) + logger.info({ with: message.key.remoteJid! }, 'shared key') + break case proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_SHARE: for(const { keyData, keyId } of protocolMsg.appStateSyncKeyShare!.keys || []) { const str = Buffer.from(keyId.keyId!).toString('base64') + logger.info({ str }, 'injecting new app state sync key') await authState.keys.setAppStateSyncKey(str, keyData) + + authState.creds.myAppStateKeyId = str } + ev.emit('auth-state.update', authState) break case proto.ProtocolMessage.ProtocolMessageType.REVOKE: @@ -303,14 +338,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { logger.debug({ msgId: dec.msgId }, 'send message receipt') - const possibleHistory = downloadIfHistory(msg) - if(possibleHistory) { - const history = await possibleHistory - logger.info({ msgId: dec.msgId, type: history.syncType }, 'recv history') - - processHistoryMessage(history) - } else { - const message = msg.deviceSentMessage?.message || msg + const message = msg.deviceSentMessage?.message || msg fullMessages.push({ key: { remoteJid: dec.chatId, @@ -323,7 +351,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { messageTimestamp: dec.timestamp, pushName: dec.pushname }) - } } if(dec.successes.length) { @@ -434,7 +461,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { ev.emit('auth-state.update', authState) } } - + await processMessage(msg, chat) if(!!msg.message && !msg.message!.protocolMessage) { chat.conversationTimestamp = toNumber(msg.messageTimestamp) @@ -453,5 +480,5 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } }) - return sock + return { ...sock, processMessage } } \ No newline at end of file diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index fb0fc50..392db09 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1,6 +1,5 @@ -import { makeMessagesRecvSocket } from "./messages-recv" -import { SocketConfig, MediaConnInfo, AnyMessageContent, MiscMessageGenerationOptions, WAMediaUploadFunction } from "../Types" +import { SocketConfig, MediaConnInfo, AnyMessageContent, MiscMessageGenerationOptions, WAMediaUploadFunction, MessageRelayOptions } from "../Types" import { encodeWAMessage, generateMessageID, generateWAMessage } from "../Utils" import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' import { proto } from "../../WAProto" @@ -8,10 +7,11 @@ import { encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, import { WA_DEFAULT_EPHEMERAL, DEFAULT_ORIGIN, MEDIA_PATH_MAP } from "../Defaults" import got from "got" import { Boom } from "@hapi/boom" +import { makeGroupsSocket } from "./groups" export const makeMessagesSocket = (config: SocketConfig) => { const { logger } = config - const sock = makeMessagesRecvSocket(config) + const sock = makeGroupsSocket(config) const { ev, authState, @@ -84,10 +84,12 @@ export const makeMessagesSocket = (config: SocketConfig) => { } const getUSyncDevices = async(jids: string[], ignoreZeroDevices: boolean) => { + jids = Array.from(new Set(jids)) const users = jids.map(jid => ({ tag: 'user', attrs: { jid: jidNormalizedUser(jid) } })) + const iq: BinaryNode = { tag: 'iq', attrs: { @@ -175,7 +177,11 @@ export const makeMessagesSocket = (config: SocketConfig) => { return node } - const relayMessage = async(jid: string, message: proto.IMessage, msgId?: string) => { + const relayMessage = async( + jid: string, + message: proto.IMessage, + { messageId: msgId, cachedGroupMetadata }: MessageRelayOptions + ) => { const { user, server } = jidDecode(jid) const isGroup = server === 'g.us' msgId = msgId || generateMessageID() @@ -188,7 +194,10 @@ export const makeMessagesSocket = (config: SocketConfig) => { if(isGroup) { const { ciphertext, senderKeyDistributionMessageKey } = await encryptSenderKeyMsgSignalProto(destinationJid, encodedMsg, authState) - const groupData = await groupMetadata(jid) + + let groupData = cachedGroupMetadata ? await cachedGroupMetadata(jid) : undefined + if(!groupData) groupData = await groupMetadata(jid) + const participantsList = groupData.participants.map(p => p.id) const devices = await getUSyncDevices(participantsList, false) @@ -384,7 +393,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { upload: waUploadToServer } ) - await relayMessage(jid, fullMsg.message, fullMsg.key.id!) + await relayMessage(jid, fullMsg.message, { messageId: fullMsg.key.id! }) process.nextTick(() => { ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' }) }) diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts index eeb7c97..273f2fc 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -1,5 +1,6 @@ import type { Contact } from "./Contact" import type { proto } from "../../WAProto" +import type { WAPatchName, ChatMutation } from "./Chat" export type KeyPair = { public: Uint8Array, private: Uint8Array } export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number } @@ -13,7 +14,7 @@ export type SignalIdentity = { identifierKey: Uint8Array } -export type CollectionType = 'regular_high' | 'regular_low' | 'critical_unblock_low' | 'critical_block' +export type LTHashState = { version: number, hash: Buffer, mutations: ChatMutation[] } export type AuthenticationCreds = { noiseKey: KeyPair @@ -24,9 +25,6 @@ export type AuthenticationCreds = { me?: Contact account?: proto.ADVSignedDeviceIdentity signalIdentities?: SignalIdentity[] - appStateVersion?: { - [T in CollectionType]: number - } myAppStateKeyId?: string firstUnuploadedPreKeyId: number serverHasPreKeys: boolean @@ -45,6 +43,9 @@ export type SignalKeyStore = { getAppStateSyncKey: (id: string) => Awaitable setAppStateSyncKey: (id: string, item: proto.IAppStateSyncKeyData | null) => Awaitable + + getAppStateSyncVersion: (name: WAPatchName) => Awaitable + setAppStateSyncVersion: (id: WAPatchName, item: LTHashState) => Awaitable } export type AuthenticationState = { diff --git a/src/Types/Chat.ts b/src/Types/Chat.ts index c6cd774..8c872ef 100644 --- a/src/Types/Chat.ts +++ b/src/Types/Chat.ts @@ -3,12 +3,14 @@ import type { proto } from "../../WAProto" /** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused' +export type WAPatchName = 'critical_block' | 'critical_unblock_low' | 'regular_low' | 'regular_high' | 'regular' + export interface PresenceData { lastKnownPresence: WAPresence lastSeen?: number } -export type ChatMutation = { action: proto.ISyncActionValue, index: [string, string] } +export type ChatMutation = { action: proto.ISyncActionValue, index: [string, string], indexMac: Uint8Array, valueMac: Uint8Array, operation: number } export type Chat = Omit & { /** unix timestamp of date when mute ends, if applicable */ diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 934e4b5..2346d91 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -1,6 +1,7 @@ import type { ReadStream } from "fs" import type { Logger } from "pino" import type { URL } from "url" +import type { GroupMetadata } from "./GroupMetadata" import { proto } from '../../WAProto' // export the WAMessage Prototypes @@ -103,6 +104,13 @@ export type AnyMessageContent = AnyRegularMessageContent | { } | { disappearingMessagesInChat: boolean | number } + +export type MessageRelayOptions = { + messageId?: string + cachedGroupMetadata?: (jid: string) => Promise + //cachedDevices?: (jid: string) => Promise +} + export type MiscMessageGenerationOptions = { /** Force message id */ messageId?: string diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index 0ed8a28..bc2c60e 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -1,12 +1,12 @@ import { Boom } from '@hapi/boom' import { aesDecrypt, hmacSign, aesEncrypt, hkdf } from "./crypto" -import { AuthenticationState, ChatModification, ChatMutation } from "../Types" +import { AuthenticationState, ChatModification, ChatMutation, WAPatchName, LTHashState } from "../Types" import { proto } from '../../WAProto' import { LT_HASH_ANTI_TAMPERING } from '../WABinary/LTHash' +import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren } from '../WABinary' +import { toNumber } from './generics' -type SyncdType = 'regular_high' | 'regular_low' - -const mutationKeys = (keydata: Uint8Array) => { +export const mutationKeys = (keydata: Uint8Array) => { const expanded = hkdf(keydata, 160, { info: 'WhatsApp Mutation Keys' }) return { indexKey: expanded.slice(0, 32), @@ -48,22 +48,40 @@ const to64BitNetworkOrder = function(e) { return Buffer.from(t) } -const generateSnapshotMac = (version: number, indexMac: Uint8Array, valueMac: Uint8Array, type: SyncdType, key: Buffer) => { - - const ltHash = () => { - const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(new Uint8Array(128).buffer, [ new Uint8Array(valueMac).buffer, new Uint8Array(indexMac).buffer ], []) - const buff = Buffer.from(result) - console.log(buff.toString('hex')) - return buff +type Mac = { indexMac: Uint8Array, valueMac: Uint8Array, operation: proto.SyncdMutation.SyncdMutationSyncdOperation } + +const computeLtHash = (initial: Uint8Array, macs: Mac[], getPrevSetValueMac: (index: Uint8Array, internalIndex: number) => Uint8Array) => { + const addBuffs: ArrayBuffer[] = [] + const subBuffs: ArrayBuffer[] = [] + for(let i = 0; i < macs.length;i++) { + const { indexMac, valueMac, operation } = macs[i] + const subBuff = getPrevSetValueMac(indexMac, i) + if(operation === proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE) { + if(!subBuff) { + throw new Boom('') + } + } else { + addBuffs.push(new Uint8Array(valueMac).buffer) + } + if(subBuff) { + subBuffs.push(new Uint8Array(subBuff).buffer) + } } - const total = Buffer.concat([ - ltHash(), - to64BitNetworkOrder(version), - Buffer.from(type, 'utf-8') - ]) - return hmacSign(total, key) + + const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(new Uint8Array(initial).buffer, addBuffs, subBuffs) + const buff = Buffer.from(result) + return buff } -const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: SyncdType, key: Buffer) => { + +export const generateSnapshotMac = (lthash: Uint8Array, version: number, name: WAPatchName, key: Buffer) => { + const total = Buffer.concat([ + lthash, + to64BitNetworkOrder(version), + Buffer.from(name, 'utf-8') + ]) + return hmacSign(total, key, 'sha256') +} +const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: WAPatchName, key: Buffer) => { const total = Buffer.concat([ snapshotMac, ...valueMacs, @@ -73,76 +91,83 @@ const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], vers return hmacSign(total, key) } -export const encodeSyncdPatch = async(action: ChatModification, lastMessageKey: proto.IMessageKey, { creds: { appStateVersion, myAppStateKeyId }, keys }: AuthenticationState) => { - let syncAction: proto.ISyncActionValue = { } - if('archive' in action) { - syncAction.archiveChatAction = { - archived: action.archive, - messageRange: { - messages: [ - { key: lastMessageKey } - ] - } - } - } else if('mute' in action) { - const value = typeof action.mute === 'number' ? true : false - syncAction.muteAction = { - muted: value, - muteEndTimestamp: typeof action.mute === 'number' ? action.mute : undefined - } - } else if('delete' in action) { - syncAction.deleteChatAction = { } - } else if('markRead' in action) { - syncAction.markChatAsReadAction = { - read: action.markRead - } - } else if('pin' in action) { - throw new Boom('Pin not supported on multi-device yet', { statusCode: 400 }) - } - - const encoded = proto.SyncActionValue.encode(syncAction).finish() +export const encodeSyncdPatch = async( + syncAction: proto.ISyncActionValue, + index: [string, string], + type: WAPatchName, + operation: proto.SyncdMutation.SyncdMutationSyncdOperation, + { creds: { myAppStateKeyId }, keys }: AuthenticationState +) => { const key = !!myAppStateKeyId ? await keys.getAppStateSyncKey(myAppStateKeyId) : undefined if(!key) { throw new Boom(`myAppStateKey not present`, { statusCode: 404 }) } - const encKeyId = Buffer.from(myAppStateKeyId, 'base64') - const index = JSON.stringify([Object.keys(action)[0], lastMessageKey.remoteJid]) + const state = { ...await keys.getAppStateSyncVersion(type) } + + const indexBuffer = Buffer.from(JSON.stringify(index)) + const encoded = proto.SyncActionData.encode({ + index: indexBuffer, + value: syncAction, + padding: new Uint8Array(0), + version: 2 + }).finish() + const keyValue = mutationKeys(key!.keyData!) const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey) - const macValue = generateMac(1, encValue, encKeyId, keyValue.valueMacKey) - const indexMacValue = hmacSign(Buffer.from(index), keyValue.indexKey) + const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey) + const indexMac = hmacSign(indexBuffer, keyValue.indexKey) - const type = 'regular_high' - const v = appStateVersion[type]+1 + state.hash = computeLtHash( + state.hash, + [ { indexMac, valueMac, operation } ], + (index) => [...state.mutations].reverse().find(m => Buffer.compare(m.indexMac, index) === 0)?.valueMac + ) + state.version += 1 - const snapshotMac = generateSnapshotMac(v, indexMacValue, macValue, type, keyValue.snapshotMacKey) + const snapshotMac = generateSnapshotMac(state.hash, state.version, type, keyValue.snapshotMacKey) const patch: proto.ISyncdPatch = { - patchMac: generatePatchMac(snapshotMac, [macValue], v, type, keyValue.patchMacKey), + patchMac: generatePatchMac(snapshotMac, [valueMac], state.version, type, keyValue.patchMacKey), snapshotMac: snapshotMac, keyId: { id: encKeyId }, mutations: [ { - operation: 1, + operation: operation, record: { index: { - blob: indexMacValue + blob: indexMac }, value: { - blob: Buffer.concat([ encValue, macValue ]) + blob: Buffer.concat([ encValue, valueMac ]) }, keyId: { id: encKeyId } } } ] } - return patch + + state.mutations = [ + ...state.mutations, + { + action: syncAction, + index, + valueMac, + indexMac, + operation + } + ] + return { patch, state } } -export const decodeSyncdPatch = async(msg: proto.ISyncdPatch, {keys}: AuthenticationState) => { +export const decodeSyncdPatch = async( + msg: proto.ISyncdPatch, + name: WAPatchName, + {keys}: AuthenticationState, + validateMacs: boolean = true +) => { const keyCache: { [_: string]: ReturnType } = { } const getKey = async(keyId: Uint8Array) => { const base64Key = Buffer.from(keyId!).toString('base64') @@ -160,42 +185,144 @@ export const decodeSyncdPatch = async(msg: proto.ISyncdPatch, {keys}: Authentica } const mutations: ChatMutation[] = [] - const failures: Boom[] = [] - /*const mainKey = getKey(msg.keyId!.id) - const mutation = msg.mutations![0]!.record - - const patchMac = generatePatchMac(msg.snapshotMac, [ mutation.value!.blob!.slice(-32) ], toNumber(msg.version!.version), 'regular_low', mainKey.patchMacKey) - console.log(patchMac) - console.log(msg.patchMac)*/ + if(validateMacs) { + const mainKey = await getKey(msg.keyId!.id) + const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32)) + + const patchMac = generatePatchMac(msg.snapshotMac, mutationmacs, toNumber(msg.version!.version), name, mainKey.patchMacKey) + if(Buffer.compare(patchMac, msg.patchMac) !== 0) { + throw new Boom('Invalid patch mac') + } + } // 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 for(const { operation, record } of msg.mutations!) { - try { - const key = await getKey(record.keyId!.id!) - const content = Buffer.from(record.value!.blob!) - const encContent = content.slice(0, -32) + 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) + if(validateMacs) { const contentHmac = generateMac(operation, encContent, record.keyId!.id!, key.valueMacKey) - if(Buffer.compare(contentHmac, content.slice(-32)) !== 0) { + if(Buffer.compare(contentHmac, ogValueMac) !== 0) { throw new Boom('HMAC content verification failed') } + } - const result = aesDecrypt(encContent, key.valueEncryptionKey) - const syncAction = proto.SyncActionData.decode(result) + const result = aesDecrypt(encContent, key.valueEncryptionKey) + const syncAction = proto.SyncActionData.decode(result) + if(validateMacs) { const hmac = hmacSign(syncAction.index, key.indexKey) if(Buffer.compare(hmac, record.index!.blob) !== 0) { throw new Boom('HMAC index verification failed') } - - const indexStr = Buffer.from(syncAction.index).toString() - mutations.push({ action: syncAction.value!, index: JSON.parse(indexStr) }) - } catch(error) { - failures.push(new Boom(error, { data: { operation, record } })) } + + const indexStr = Buffer.from(syncAction.index).toString() + mutations.push({ + action: syncAction.value!, + index: JSON.parse(indexStr), + indexMac: record.index!.blob!, + valueMac: ogValueMac, + operation: operation + }) } - return { mutations, failures } + return { mutations } +} + +export const extractSyncdPatches = (result: BinaryNode) => { + const syncNode = getBinaryNodeChild(result, 'sync') + const collectionNode = getBinaryNodeChild(syncNode, 'collection') + const patchesNode = getBinaryNodeChild(collectionNode, 'patches') + + const patches = getBinaryNodeChildren(patchesNode || collectionNode, 'patch') + const syncds: proto.ISyncdPatch[] = [] + const name = collectionNode.attrs.name as WAPatchName + for(let { content } of patches) { + if(content) { + const syncd = proto.SyncdPatch.decode(content! as Uint8Array) + if(!syncd.version) { + syncd.version = { version: +collectionNode.attrs.version+1 } + } + syncds.push(syncd) + } + } + return { syncds, name } +} + +export const decodePatches = async( + { syncds, name }: ReturnType, + initial: LTHashState, + auth: AuthenticationState, + validateMacs: boolean = true +) => { + const successfulMutations: ChatMutation[] = [] + + let current = initial.hash + let currentVersion = initial.version + for(const syncd of syncds) { + const { mutations, version, keyId, snapshotMac } = syncd + const macs = mutations.map( + m => ({ + operation: m.operation!, + indexMac: m.record.index!.blob!, + valueMac: m.record.value!.blob!.slice(-32) + }) + ) + + currentVersion = toNumber(version.version!) + + current = computeLtHash(current, macs, (index, maxIndex) => { + let value: Uint8Array + for(const item of initial.mutations) { + if(Buffer.compare(item.indexMac, index) === 0) { + value = item.valueMac + } + } + for(const { version, mutations } of syncds) { + const versionNum = toNumber(version.version!) + const mutationIdx = mutations.findIndex(m => { + return Buffer.compare(m.record!.index!.blob, index) === 0 + }) + + if(mutationIdx >= 0 && (versionNum < currentVersion || mutationIdx < maxIndex)) { + value = mutations[mutationIdx].record!.value!.blob!.slice(-32) + } + + if(versionNum >= currentVersion) { + break + } + } + return value + }) + + if(validateMacs) { + const base64Key = Buffer.from(keyId!.id!).toString('base64') + const keyEnc = await auth.keys.getAppStateSyncKey(base64Key) + if(!keyEnc) { + throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 500 }) + } + const result = mutationKeys(keyEnc.keyData!) + const computedSnapshotMac = generateSnapshotMac(current, currentVersion, name, result.snapshotMacKey) + if(Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) { + throw new Boom(`failed to verify LTHash at ${currentVersion}`, { statusCode: 500 }) + } + } + + const decodeResult = await decodeSyncdPatch(syncd, name, auth!, validateMacs) + console.log(currentVersion, decodeResult.mutations) + successfulMutations.push(...decodeResult.mutations) + } + return { + newMutations: successfulMutations, + state: { + hash: current, + version: currentVersion, + mutations: [...initial.mutations, ...successfulMutations] + } as LTHashState + } } \ No newline at end of file diff --git a/src/Utils/history.ts b/src/Utils/history.ts index b304c0a..b5df9c2 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -5,12 +5,6 @@ import { inflate } from "zlib"; const inflatePromise = promisify(inflate) -export const downloadIfHistory = (message: proto.IMessage) => { - if(message.protocolMessage?.historySyncNotification) { - return downloadHistory(message.protocolMessage!.historySyncNotification) - } -} - export const downloadHistory = async(msg: proto.IHistorySyncNotification) => { const stream = await downloadContentFromMessage(msg, 'history') let buffer = Buffer.from([]) diff --git a/src/Utils/validate-connection.ts b/src/Utils/validate-connection.ts index b327d31..2709852 100644 --- a/src/Utils/validate-connection.ts +++ b/src/Utils/validate-connection.ts @@ -1,7 +1,7 @@ import { Boom } from '@hapi/boom' import { randomBytes } from 'crypto' import { proto } from '../../WAProto' -import type { AuthenticationState, SocketConfig, SignalKeyStore, AuthenticationCreds, KeyPair } from "../Types" +import type { AuthenticationState, SocketConfig, SignalKeyStore, AuthenticationCreds, KeyPair, LTHashState } from "../Types" import { Curve, hmacSign, signedKeyPair } from './crypto' import { encodeInt, generateRegistrationId } from './generics' import { BinaryNode, S_WHATSAPP_NET, jidDecode, Binary } from '../WABinary' @@ -83,22 +83,25 @@ export const generateRegistrationNode = ( } export const initInMemoryKeyStore = ( - { preKeys, sessions, senderKeys, appStateSyncKeys }: { + { preKeys, sessions, senderKeys, appStateSyncKeys, appStateVersions }: { preKeys?: { [k: number]: KeyPair }, sessions?: { [k: string]: any }, senderKeys?: { [k: string]: any } - appStateSyncKeys?: { [k: string]: proto.IAppStateSyncKeyData } + appStateSyncKeys?: { [k: string]: proto.IAppStateSyncKeyData }, + appStateVersions?: { [k: string]: LTHashState }, } = { }, ) => { preKeys = preKeys || { } sessions = sessions || { } senderKeys = senderKeys || { } appStateSyncKeys = appStateSyncKeys || { } + appStateVersions = appStateVersions || { } return { preKeys, sessions, senderKeys, appStateSyncKeys, + appStateVersions, getPreKey: keyId => preKeys[keyId], setPreKey: (keyId, pair) => { if(pair) preKeys[keyId] = pair @@ -125,6 +128,16 @@ export const initInMemoryKeyStore = ( setAppStateSyncKey: (id, item) => { if(item) appStateSyncKeys[id] = item else delete appStateSyncKeys[id] + }, + getAppStateSyncVersion: id => { + const obj = appStateVersions[id] + if(obj) { + return obj + } + }, + setAppStateSyncVersion: (id, item) => { + if(item) appStateVersions[id] = item + else delete appStateVersions[id] } } as SignalKeyStore } diff --git a/src/WABinary/LTHash.ts b/src/WABinary/LTHash.ts index b11053b..85ea166 100644 --- a/src/WABinary/LTHash.ts +++ b/src/WABinary/LTHash.ts @@ -29,13 +29,13 @@ class d { } _addSingle(e, t) { var r = this; - const n = new Uint8Array(hkdf(t, o, { info: r.salt })).buffer; + const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer; return r.performPointwiseWithOverflow(e, n, ((e,t)=>e + t)) } _subtractSingle(e, t) { var r = this; - const n = new Uint8Array(hkdf(t, o, { info: r.salt })).buffer; + const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer; return r.performPointwiseWithOverflow(e, n, ((e,t)=>e - t)) } performPointwiseWithOverflow(e, t, r) {