diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 15c7180..0d937f2 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -35,7 +35,8 @@ export const MEDIA_PATH_MAP: { [T in MediaType]: string } = { document: '/mms/document', audio: '/mms/audio', sticker: '/mms/image', - history: '' + history: '', + 'md-app-state': '' } export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index c2cbf42..6513390 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -167,7 +167,16 @@ export const makeChatsSocket = (config: SocketConfig) => { }) } - const collectionSync = async(collections: { name: WAPatchName, version: number }[]) => { + const resyncAppState = async(collections: WAPatchName[], fromScratch: boolean = false, returnSnapshot: boolean = false) => { + const states = { } as { [T in WAPatchName]: LTHashState } + for(const name of collections) { + let state: LTHashState = fromScratch ? undefined : await authState.keys.getAppStateSyncVersion(name) + if(!state) state = { version: 0, hash: Buffer.alloc(128), mutations: [] } + + states[name] = state + + logger.info(`resyncing ${name} from v${state.version}`) + } const result = await query({ tag: 'iq', attrs: { @@ -180,25 +189,35 @@ export const makeChatsSocket = (config: SocketConfig) => { tag: 'sync', attrs: { }, content: collections.map( - ({ name, version }) => ({ + (name) => ({ tag: 'collection', - attrs: { name, version: version.toString(), return_snapshot: 'true' } + attrs: { + name, + version: states[name].version.toString(), + return_snapshot: returnSnapshot ? 'true' : 'false' + } }) ) } ] }) - 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 decoded = extractSyncdPatches(result) // extract from binary node + + for(const key in decoded) { + const name = key as WAPatchName + // only process if there are syncd patches + if(decoded[name].length) { + const { newMutations, state: newState } = await decodePatches(name, decoded[name], states[name], authState, true) + + await authState.keys.setAppStateSyncVersion(name, newState) + + logger.info(`synced ${name} to v${newState.version}`) + processSyncActions(newMutations) + } + } + + ev.emit('auth-state.update', authState) } /** @@ -282,7 +301,6 @@ export const makeChatsSocket = (config: SocketConfig) => { } const processSyncActions = (actions: ChatMutation[]) => { - const updates: { [jid: string]: Partial } = {} const contactUpdates: { [jid: string]: Contact } = {} const msgDeletes: proto.IMessageKey[] = [] @@ -337,10 +355,10 @@ export const makeChatsSocket = (config: SocketConfig) => { const appPatch = async(patchCreate: WAPatchCreate) => { const name = patchCreate.type try { - await resyncState(name, false) + await resyncAppState([name]) } catch(error) { logger.info({ name, error: error.stack }, 'failed to sync state from version, trying from scratch') - await resyncState(name, true) + await resyncAppState([name], true) } const { patch, state } = await encodeSyncdPatch( @@ -349,7 +367,7 @@ export const makeChatsSocket = (config: SocketConfig) => { ) 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 result = await decodePatches(name, [{ ...patch, version: { version: state.version }, }], initial, authState) const node: BinaryNode = { tag: 'iq', @@ -396,52 +414,6 @@ export const makeChatsSocket = (config: SocketConfig) => { return appPatch(patch) } - const fetchAppState = async(name: WAPatchName, fromVersion: number) => { - const result = await query({ - tag: 'iq', - attrs: { - type: 'set', - xmlns: 'w:sync:app:state', - to: S_WHATSAPP_NET - }, - content: [ - { - tag: 'sync', - attrs: { }, - content: [ - { - tag: 'collection', - attrs: { - name, - version: fromVersion.toString(), - return_snapshot: 'false' - } - } - ] - } - ] - }) - 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) - - ev.emit('auth-state.update', authState) - } - ws.on('CB:presence', handlePresenceUpdate) ws.on('CB:chatstate', handlePresenceUpdate) @@ -461,7 +433,8 @@ 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 WAPatchName, false) + const name = update.attrs.name as WAPatchName + resyncAppState([name], false) .catch(err => logger.error({ trace: err.stack, node }, `failed to sync state`)) } }) @@ -471,6 +444,7 @@ export const makeChatsSocket = (config: SocketConfig) => { sendPresenceUpdate('available') fetchBlocklist() fetchPrivacySettings() + resyncAppState([ 'critical_block', 'critical_unblock_low' ]) } }) @@ -485,7 +459,7 @@ export const makeChatsSocket = (config: SocketConfig) => { fetchStatus, updateProfilePicture, updateBlockStatus, - resyncState, + resyncAppState, chatModify, } } \ No newline at end of file diff --git a/src/Types/Message.ts b/src/Types/Message.ts index c890a82..335c9f0 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -52,7 +52,7 @@ type WithDimensions = { width?: number height?: number } -export type MediaType = 'image' | 'video' | 'sticker' | 'audio' | 'document' | 'history' +export type MediaType = 'image' | 'video' | 'sticker' | 'audio' | 'document' | 'history' | 'md-app-state' export type AnyMediaMessageContent = ( ({ image: WAMediaUpload diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index 8deb9aa..e06393b 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -5,6 +5,7 @@ import { proto } from '../../WAProto' import { LT_HASH_ANTI_TAMPERING } from './lt-hash' import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren } from '../WABinary' import { toNumber } from './generics' +import { downloadContentFromMessage, } from './messages-media' export const mutationKeys = (keydata: Uint8Array) => { const expanded = hkdf(keydata, 160, { info: 'WhatsApp Mutation Keys' }) @@ -235,26 +236,44 @@ export const decodeSyncdPatch = async( export const extractSyncdPatches = (result: BinaryNode) => { const syncNode = getBinaryNodeChild(result, 'sync') - const collectionNode = getBinaryNodeChild(syncNode, 'collection') - const patchesNode = getBinaryNodeChild(collectionNode, 'patches') + const collectionNodes = getBinaryNodeChildren(syncNode, 'collection') + + const final = { } as { [T in WAPatchName]: proto.ISyncdPatch[] } + for(const collectionNode of collectionNodes) { + 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 } + 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) } - syncds.push(syncd) } + + final[name] = syncds } - return { syncds, name } + + return final +} + +export const downloadExternalPatch = async(blob: proto.IExternalBlobReference) => { + const stream = await downloadContentFromMessage(blob, 'md-app-state') + let buffer = Buffer.from([]) + for await(const chunk of stream) { + buffer = Buffer.concat([buffer, chunk]) + } + const syncData = proto.SyncdMutations.decode(buffer) + return syncData } export const decodePatches = async( - { syncds, name }: ReturnType, + name: WAPatchName, + syncds: proto.ISyncdPatch[], initial: LTHashState, auth: AuthenticationState, validateMacs: boolean = true @@ -263,8 +282,13 @@ export const decodePatches = async( let current = initial.hash let currentVersion = initial.version + for(const syncd of syncds) { - const { mutations, version, keyId, snapshotMac } = syncd + const { mutations, version, keyId, snapshotMac, externalMutations } = syncd + if(externalMutations) { + const ref = await downloadExternalPatch(externalMutations) + mutations.push(...ref.mutations) + } const macs = mutations.map( m => ({ operation: m.operation!, @@ -308,7 +332,7 @@ export const decodePatches = async( 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 }) + throw new Boom(`failed to verify LTHash at ${currentVersion} of ${name}`, { statusCode: 500 }) } } diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index 3df3a76..05f3253 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -16,9 +16,11 @@ import { hkdf } from './crypto' import { DEFAULT_ORIGIN } from '../Defaults' export const hkdfInfoKey = (type: MediaType) => { - if(type === 'sticker') type = 'image' + let str: string = type + if(type === 'sticker') str = 'image' + if(type === 'md-app-state') str = 'App State' - let hkdfInfo = type[0].toUpperCase() + type.slice(1) + let hkdfInfo = str[0].toUpperCase() + str.slice(1) return `WhatsApp ${hkdfInfo} Keys` } /** generates all the keys required to encrypt/decrypt & sign a media message */ diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 7d1a3a3..491cc0c 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -38,7 +38,8 @@ const MIMETYPE_MAP: { [T in MediaType]: string } = { document: 'application/pdf', audio: 'audio/ogg; codecs=opus', sticker: 'image/webp', - history: 'application/x-protobuf' + history: 'application/x-protobuf', + "md-app-state": 'application/x-protobuf', } const MessageTypeProto = {