finalize multi-device

This commit is contained in:
Adhiraj Singh
2021-09-15 13:40:02 +05:30
parent 9cba28e891
commit f267f27ada
82 changed files with 35228 additions and 10644 deletions

198
src/Utils/chat-utils.ts Normal file
View File

@@ -0,0 +1,198 @@
import { Boom } from '@hapi/boom'
import { aesDecrypt, hmacSign, aesEncrypt, hkdf } from "./generics"
import { AuthenticationState, ChatModification } from "../Types"
import { proto } from '../../WAProto'
import { LT_HASH_ANTI_TAMPERING } from '../WABinary/LTHash'
type SyncdType = 'regular_high' | 'regular_low'
const mutationKeys = (keydata: string) => {
const expanded = hkdf(Buffer.from(keydata, 'base64'), 160, { info: 'WhatsApp Mutation Keys' })
return {
indexKey: expanded.slice(0, 32),
valueEncryptionKey: expanded.slice(32, 64),
valueMacKey: expanded.slice(64, 96),
snapshotMacKey: expanded.slice(96, 128),
patchMacKey: expanded.slice(128, 160)
}
}
const generateMac = (operation: proto.SyncdMutation.SyncdMutationSyncdOperation, data: Buffer, keyId: Uint8Array | string, key: Buffer) => {
const getKeyData = () => {
let r: number
switch (operation) {
case proto.SyncdMutation.SyncdMutationSyncdOperation.SET:
r = 0x01
break
case proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE:
r = 0x02
break
}
const buff = Buffer.from([r])
return Buffer.concat([ buff, Buffer.from(keyId as any, 'base64') ])
}
const keyData = getKeyData()
const last = Buffer.alloc(8) // 8 bytes
last.set([ keyData.length ], last.length-1)
const total = Buffer.concat([ keyData, data, last ])
const hmac = hmacSign(total, key, 'sha512')
return hmac.slice(0, 32)
}
const to64BitNetworkOrder = function(e) {
const t = new ArrayBuffer(8)
new DataView(t).setUint32(4, e, !1)
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
}
const total = Buffer.concat([
ltHash(),
to64BitNetworkOrder(version),
Buffer.from(type, 'utf-8')
])
return hmacSign(total, key)
}
const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: SyncdType, key: Buffer) => {
const total = Buffer.concat([
snapshotMac,
...valueMacs,
to64BitNetworkOrder(version),
Buffer.from(type, 'utf-8')
])
return hmacSign(total, key)
}
export const encodeSyncdPatch = (action: ChatModification, lastMessageKey: proto.IMessageKey, { creds: { appStateSyncKeys: [key], appStateVersion } }: 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()
const index = JSON.stringify([Object.keys(action)[0], lastMessageKey.remoteJid])
const keyValue = mutationKeys(key.keyData!.keyData! as any)
const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey)
const macValue = generateMac(1, encValue, key.keyId!.keyId, keyValue.valueMacKey)
const indexMacValue = hmacSign(Buffer.from(index), keyValue.indexKey)
const type = 'regular_high'
const v = appStateVersion[type]+1
const snapshotMac = generateSnapshotMac(v, indexMacValue, macValue, type, keyValue.snapshotMacKey)
const patch: proto.ISyncdPatch = {
patchMac: generatePatchMac(snapshotMac, [macValue], v, type, keyValue.patchMacKey),
snapshotMac: snapshotMac,
keyId: { id: key.keyId.keyId },
mutations: [
{
operation: 1,
record: {
index: {
blob: indexMacValue
},
value: {
blob: Buffer.concat([ encValue, macValue ])
},
keyId: { id: key.keyId.keyId }
}
}
]
}
return patch
}
export const decodeSyncdPatch = (msg: proto.ISyncdPatch, {creds}: AuthenticationState) => {
const keyCache: { [_: string]: ReturnType<typeof mutationKeys> } = { }
const getKey = (keyId: Uint8Array) => {
const base64Key = Buffer.from(keyId!).toString('base64')
let key = keyCache[base64Key]
if(!key) {
const keyEnc = creds.appStateSyncKeys?.find(k => (
(k.keyId!.keyId as any) === base64Key
))
if(!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 500, data: msg })
}
const result = mutationKeys(keyEnc.keyData!.keyData as any)
keyCache[base64Key] = result
key = result
}
return key
}
const mutations: { action: proto.ISyncActionValue, index: [string, string] }[] = []
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)*/
// 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 = getKey(record.keyId!.id!)
const content = Buffer.from(record.value!.blob!)
const encContent = content.slice(0, -32)
const contentHmac = generateMac(operation, encContent, record.keyId!.id!, key.valueMacKey)
if(Buffer.compare(contentHmac, content.slice(-32)) !== 0) {
throw new Boom('HMAC content verification failed')
}
const result = aesDecrypt(encContent, key.valueEncryptionKey)
const syncAction = proto.SyncActionData.decode(result)
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 } }))
}
}
return { mutations, failures }
}

View File

@@ -1,63 +1,103 @@
import { Boom } from '@hapi/boom'
import BinaryNode from "../BinaryNode"
import { aesDecrypt, hmacSign } from "./generics"
import { DisconnectReason, WATag } from "../Types"
import { unpadRandomMax16 } from "./generics"
import { AuthenticationState } from "../Types"
import { areJidsSameUser, BinaryNode as BinaryNodeM, encodeBinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser } from '../WABinary'
import { decryptGroupSignalProto, decryptSignalProto, processSenderKeyMessage } from './signal'
import { proto } from '../../WAProto'
export const decodeWAMessage = (
message: string | Buffer,
auth: { macKey: Buffer, encKey: Buffer },
fromMe: boolean=false
) => {
type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status'
let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message
if (commaIndex < 0) throw new Boom('invalid message', { data: message }) // if there was no comma, then this message must be not be valid
if (message[commaIndex+1] === ',') commaIndex += 1
let data = message.slice(commaIndex+1, message.length)
// get the message tag.
// If a query was done, the server will respond with the same message tag we sent the query with
const messageTag: string = message.slice(0, commaIndex).toString()
let json: any
let tags: WATag
if (data.length > 0) {
if (typeof data === 'string') {
json = JSON.parse(data) // parse the JSON
export const decodeMessageStanza = async(stanza: BinaryNodeM, auth: AuthenticationState) => {
const deviceIdentity = (stanza.content as BinaryNodeM[])?.find(m => m.tag === 'device-identity')
const deviceIdentityBytes = deviceIdentity ? deviceIdentity.content as Buffer : undefined
let msgType: MessageType
let chatId: string
let author: string
const msgId: string = stanza.attrs.id
const from: string = stanza.attrs.from
const participant: string | undefined = stanza.attrs.participant
const recipient: string | undefined = stanza.attrs.recipient
const isMe = (jid: string) => areJidsSameUser(jid, auth.creds.me!.id)
if(isJidUser(from)) {
if(recipient) {
if(!isMe(from)) {
throw new Boom('')
}
chatId = recipient
} else {
const { macKey, encKey } = auth || {}
if (!macKey || !encKey) {
throw new Boom('recieved encrypted buffer when auth creds unavailable', { data: message, statusCode: DisconnectReason.badSession })
}
/*
If the data recieved was not a JSON, then it must be an encrypted message.
Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys
*/
if (fromMe) {
tags = [data[0], data[1]]
data = data.slice(2, data.length)
}
const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message
data = data.slice(32, data.length) // the actual message
const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey
if (checksum.equals(computedChecksum)) {
// the checksum the server sent, must match the one we computed for the message to be valid
const decrypted = aesDecrypt(data, encKey) // decrypt using AES
json = BinaryNode.from(decrypted) // decode the binary message into a JSON array
} else {
throw new Boom('Bad checksum', {
data: {
received: checksum.toString('hex'),
computed: computedChecksum.toString('hex'),
data: data.slice(0, 80).toString(),
tag: messageTag,
message: message.slice(0, 80).toString()
},
statusCode: DisconnectReason.badSession
})
}
}
chatId = from
}
msgType = 'chat'
author = from
} else if(isJidGroup(from)) {
if(!participant) {
throw new Boom('No participant in group message')
}
msgType = 'group'
author = participant
chatId = from
} else if(isJidBroadcast(from)) {
if(!participant) {
throw new Boom('No participant in group message')
}
const isParticipantMe = isMe(participant)
if(isJidStatusBroadcast(from)) {
msgType = isParticipantMe ? 'direct_peer_status' : 'other_status'
} else {
msgType = isParticipantMe ? 'peer_broadcast' : 'other_broadcast'
}
chatId = from
author = participant
}
const sender = msgType === 'chat' ? author : chatId
const successes: proto.Message[] = []
const failures: { error: Boom }[] = []
if(Array.isArray(stanza.content)) {
for(const { tag, attrs, content } of stanza.content as BinaryNodeM[]) {
if(tag !== 'enc') continue
if(!Buffer.isBuffer(content) && !(content instanceof Uint8Array)) continue
try {
let msgBuffer: Buffer
const e2eType = attrs.type
switch(e2eType) {
case 'skmsg':
msgBuffer = await decryptGroupSignalProto(sender, author, content, auth)
break
case 'pkmsg':
case 'msg':
const user = isJidUser(sender) ? sender : author
msgBuffer = await decryptSignalProto(user, e2eType, content as Buffer, auth)
break
}
const msg = proto.Message.decode(unpadRandomMax16(msgBuffer))
if(msg.senderKeyDistributionMessage) {
await processSenderKeyMessage(author, msg.senderKeyDistributionMessage, auth)
}
successes.push(msg)
} catch(error) {
failures.push({ error: new Boom(error, { data: Buffer.from(encodeBinaryNode(stanza)).toString('base64') }) })
}
}
}
return {
msgId,
chatId,
author,
from,
timestamp: +stanza.attrs.t,
participant,
recipient,
pushname: stanza.attrs.notify,
successes,
failures
}
return [messageTag, json, tags] as const
}

View File

@@ -1,7 +1,10 @@
import { Boom } from '@hapi/boom'
import CurveCrypto from 'libsignal/src/curve25519_wrapper'
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto'
import HKDF from 'futoin-hkdf'
import { platform, release } from 'os'
import { KeyPair } from '../Types'
import { proto } from '../../WAProto'
import { Binary } from '../WABinary'
const PLATFORM_MAP = {
'aix': 'AIX',
@@ -9,6 +12,7 @@ const PLATFORM_MAP = {
'win32': 'Windows',
'android': 'Android'
}
export const Browsers = {
ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string],
macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string],
@@ -16,6 +20,118 @@ export const Browsers = {
/** The appropriate browser based on your OS & release */
appropriate: browser => [ PLATFORM_MAP[platform()] || 'Ubuntu', browser, release() ] as [string, string, string]
}
export const BufferJSON = {
replacer: (k, value: any) => {
if(Buffer.isBuffer(value) || value instanceof Uint8Array || value?.type === 'Buffer') {
return { type: 'Buffer', data: Buffer.from(value?.data || value).toString('base64') }
}
return value
},
reviver: (_, value: any) => {
if(typeof value === 'object' && !!value && (value.buffer === true || value.type === 'Buffer')) {
const val = value.data || value.value
return typeof val === 'string' ? Buffer.from(val, 'base64') : Buffer.from(val)
}
return value
}
}
export const writeRandomPadMax16 = function(e: Binary) {
function r(e: Binary, t: number) {
for (var r = 0; r < t; r++)
e.writeUint8(t)
}
var t = randomBytes(1)
r(e, 1 + (15 & t[0]))
return e
}
export const unpadRandomMax16 = (e: Uint8Array | Buffer) => {
const t = new Uint8Array(e);
if (0 === t.length) {
throw new Error('unpadPkcs7 given empty bytes');
}
var r = t[t.length - 1];
if (r > t.length) {
throw new Error(`unpad given ${t.length} bytes, but pad is ${r}`);
}
return new Uint8Array(t.buffer, t.byteOffset, t.length - r);
}
export const encodeWAMessage = (message: proto.IMessage) => (
Buffer.from(
writeRandomPadMax16(
new Binary(proto.Message.encode(message).finish())
).readByteArray()
)
)
export const generateCurveKeyPair = (): KeyPair => {
const { pubKey, privKey } = CurveCrypto.keyPair(randomBytes(32))
return {
private: Buffer.from(privKey),
public: Buffer.from(pubKey)
}
}
export const generateSharedKey = (privateKey: Uint8Array, publicKey: Uint8Array) => {
const shared = CurveCrypto.sharedSecret(publicKey, privateKey)
return Buffer.from(shared)
}
export const curveSign = (privateKey: Uint8Array, buf: Uint8Array) => (
Buffer.from(CurveCrypto.sign(privateKey, buf))
)
export const curveVerify = (pubKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => {
try {
CurveCrypto.verify(pubKey, message, signature)
return true
} catch(error) {
if(error.message.includes('Invalid')) {
return false
}
throw error
}
}
export const signedKeyPair = (keyPair: KeyPair, keyId: number) => {
const signKeys = generateCurveKeyPair()
const pubKey = new Uint8Array(33)
pubKey.set([5], 0)
pubKey.set(signKeys.public, 1)
const signature = curveSign(keyPair.private, pubKey)
return { keyPair: signKeys, signature, keyId }
}
export const generateRegistrationId = () => (
Uint16Array.from(randomBytes(2))[0] & 0x3fff
)
export const encodeInt = (e: number, t: number) => {
for (var r = t, a = new Uint8Array(e), i = e - 1; i >= 0; i--) {
a[i] = 255 & r
r >>>= 8
}
return a
}
export const encodeBigEndian = (e: number, t=4) => {
let r = e;
let a = new Uint8Array(t);
for (let i = t - 1; i >= 0; i--) {
a[i] = 255 & r
r >>>= 8
}
return a
}
export const toNumber = (t: Long | number) => (typeof t?.['low'] !== 'undefined' ? t['low'] : t) as number
export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net')
@@ -48,7 +164,7 @@ export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
return Buffer.concat([aes.update(buffer), aes.final()])
}
// encrypt AES 256 CBC; where a random IV is prefixed to the buffer
export function aesEncrypt(buffer: Buffer, key: Buffer) {
export function aesEncrypt(buffer: Buffer | Uint8Array, key: Buffer) {
const IV = randomBytes(16)
const aes = createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
@@ -59,20 +175,47 @@ export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
}
// sign HMAC using SHA 256
export function hmacSign(buffer: Buffer, key: Buffer) {
return createHmac('sha256', key).update(buffer).digest()
export function hmacSign(buffer: Buffer | Uint8Array, key: Buffer | Uint8Array, variant: 'sha256' | 'sha512' = 'sha256') {
return createHmac(variant, key).update(buffer).digest()
}
export function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest()
}
// HKDF key expansion
export function hkdf(buffer: Buffer, expandedLength: number, info = null) {
return HKDF(buffer, expandedLength, { salt: Buffer.alloc(32), info: info, hash: 'SHA-256' })
// from: https://github.com/benadida/node-hkdf
export function hkdf(buffer: Buffer, expandedLength: number, { info, salt }: { salt?: Buffer, info?: string }) {
const hashAlg = 'sha256'
const hashLength = 32
salt = salt || Buffer.alloc(hashLength)
// now we compute the PRK
const prk = createHmac(hashAlg, salt).update(buffer).digest()
let prev = Buffer.from([])
const buffers = []
const num_blocks = Math.ceil(expandedLength / hashLength)
const infoBuff = Buffer.from(info || [])
for (var i=0; i<num_blocks; i++) {
const hmac = createHmac(hashAlg, prk)
// XXX is there a more optimal way to build up buffers?
const input = Buffer.concat([
prev,
infoBuff,
Buffer.from(String.fromCharCode(i + 1))
]);
hmac.update(input)
prev = hmac.digest()
buffers.push(prev)
}
return Buffer.concat(buffers, expandedLength)
}
/** unix timestamp of a date in seconds */
export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000)
export type DebouncedTimeout = ReturnType<typeof debouncedTimeout>
export const debouncedTimeout = (intervalMs: number = 1000, task: () => void = undefined) => {
let timeout: NodeJS.Timeout
return {
@@ -135,14 +278,5 @@ export async function promiseTimeout<T>(ms: number, promise: (resolve: (v?: T)=>
.finally (cancel)
return p as Promise<T>
}
// whatsapp requires a message tag for every message, we just use the timestamp as one
export function generateMessageTag(epoch?: number) {
let tag = unixTimestampSeconds().toString()
if (epoch) tag += '.--' + epoch // attach epoch if provided
return tag
}
// generate a random 16 byte client ID
export const generateClientID = () => randomBytes(16).toString('base64')
// generate a random ID to attach to a message
// this is the format used for WA Web 4 byte hex prefixed with 3EB0
export const generateMessageID = () => '3EB0' + randomBytes(4).toString('hex').toUpperCase()
export const generateMessageID = () => 'BAE5' + randomBytes(6).toString('hex').toUpperCase()

25
src/Utils/history.ts Normal file
View File

@@ -0,0 +1,25 @@
import { downloadContentFromMessage } from "./messages-media";
import { proto } from "../../WAProto";
import { promisify } from 'util'
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([])
for await(const chunk of stream) {
buffer = Buffer.concat([buffer, chunk])
}
// decompress buffer
buffer = await inflatePromise(buffer)
const syncData = proto.HistorySync.decode(buffer)
return syncData
}

View File

@@ -1,21 +1,18 @@
import type { Agent } from 'https'
import type { Logger } from 'pino'
import type { IAudioMetadata } from 'music-metadata'
import { Boom } from '@hapi/boom'
import * as Crypto from 'crypto'
import { Readable, Transform } from 'stream'
import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs'
import { exec } from 'child_process'
import { tmpdir } from 'os'
import HttpsProxyAgent from 'https-proxy-agent'
import { URL } from 'url'
import { MessageType, WAMessageContent, WAMessageProto, WAGenericMediaMessage, WAMediaUpload } from '../Types'
import got, { Options, Response } from 'got'
import { join } from 'path'
import { generateMessageID, hkdf } from './generics'
import { Boom } from '@hapi/boom'
import { MediaType } from '../Types'
import { DEFAULT_ORIGIN } from '../Defaults'
import { once } from 'events'
import got, { Options, Response } from 'got'
import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType } from '../Types'
import { generateMessageID, hkdf } from './generics'
import { DEFAULT_ORIGIN } from '../Defaults'
export const hkdfInfoKey = (type: MediaType) => {
if(type === 'sticker') type = 'image'
@@ -29,7 +26,7 @@ export function getMediaKeys(buffer, mediaType: MediaType) {
buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
}
// expand using HKDF to 112 bytes, also pass in the relevant app info
const expandedMediaKey = hkdf(buffer, 112, hkdfInfoKey(mediaType))
const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
return {
iv: expandedMediaKey.slice(0, 16),
cipherKey: expandedMediaKey.slice(16, 48),
@@ -54,20 +51,18 @@ const extractVideoThumb = async (
export const compressImage = async (bufferOrFilePath: Buffer | string) => {
const { read, MIME_JPEG } = await import('jimp')
const jimp = await read(bufferOrFilePath as any)
const result = await jimp.resize(48, 48).getBufferAsync(MIME_JPEG)
const result = await jimp.resize(32, 32).getBufferAsync(MIME_JPEG)
return result
}
export const generateProfilePicture = async (buffer: Buffer) => {
export const generateProfilePicture = async (bufferOrFilePath: Buffer | string) => {
const { read, MIME_JPEG } = await import('jimp')
const jimp = await read (buffer)
const jimp = await read(bufferOrFilePath as any)
const min = Math.min(jimp.getWidth (), jimp.getHeight ())
const cropped = jimp.crop (0, 0, min, min)
return {
img: await cropped.resize(640, 640).getBufferAsync (MIME_JPEG),
preview: await cropped.resize(96, 96).getBufferAsync (MIME_JPEG)
img: await cropped.resize(640, 640).getBufferAsync(MIME_JPEG),
}
}
export const ProxyAgent = (host: string | URL) => HttpsProxyAgent(host) as any as Agent
/** gets the SHA256 of the given media message */
export const mediaMessageSHA256B64 = (message: WAMessageContent) => {
const media = Object.values(message)[0] as WAGenericMediaMessage
@@ -113,7 +108,7 @@ export async function generateThumbnail(
} else if(mediaType === 'video') {
const imgFilename = join(tmpdir(), generateMessageID() + '.jpg')
try {
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 48, height: 48 })
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 32, height: 32 })
const buff = await fs.readFile(imgFilename)
thumbnail = buff.toString('base64')
@@ -205,6 +200,47 @@ export const encryptedStream = async(media: WAMediaUpload, mediaType: MediaType,
didSaveToTmpPath
}
}
const DEF_HOST = 'mmg.whatsapp.net'
export const downloadContentFromMessage = async(
{ mediaKey, directPath, url }: { mediaKey?: Uint8Array, directPath?: string, url?: string },
type: MediaType
) => {
const downloadUrl = url || `https://${DEF_HOST}${directPath}`
// download the message
const fetched = await getGotStream(downloadUrl, {
headers: { Origin: DEFAULT_ORIGIN }
})
let remainingBytes = Buffer.from([])
const { cipherKey, iv } = getMediaKeys(mediaKey, type)
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
const output = new Transform({
transform(chunk, _, callback) {
let data = Buffer.concat([remainingBytes, chunk])
const decryptLength =
Math.floor(data.length / 16) * 16
remainingBytes = data.slice(decryptLength)
data = data.slice(0, decryptLength)
try {
this.push(aes.update(data))
callback()
} catch(error) {
callback(error)
}
},
final(callback) {
try {
this.push(aes.final())
callback()
} catch(error) {
callback(error)
}
},
})
return fetched.pipe(output, { end: true })
}
/**
* Decode a media message (video, image, document, audio) & return decrypted buffer
* @param message the media message you want to decode
@@ -237,39 +273,7 @@ export async function decryptMediaMessageBuffer(message: WAMessageContent): Prom
} else {
messageContent = message[type]
}
// download the message
const fetched = await getGotStream(messageContent.url, {
headers: { Origin: DEFAULT_ORIGIN }
})
let remainingBytes = Buffer.from([])
const { cipherKey, iv } = getMediaKeys(messageContent.mediaKey, type.replace('Message', '') as MediaType)
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
const output = new Transform({
transform(chunk, _, callback) {
let data = Buffer.concat([remainingBytes, chunk])
const decryptLength =
Math.floor(data.length / 16) * 16
remainingBytes = data.slice(decryptLength)
data = data.slice(0, decryptLength)
try {
this.push(aes.update(data))
callback()
} catch(error) {
callback(error)
}
},
final(callback) {
try {
this.push(aes.final())
callback()
} catch(error) {
callback(error)
}
},
})
return fetched.pipe(output, { end: true })
return downloadContentFromMessage(messageContent, type.replace('Message', '') as MediaType)
}
export function extensionForMediaMessage(message: WAMessageContent) {
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
@@ -283,10 +287,10 @@ export function extensionForMediaMessage(message: WAMessageContent) {
extension = '.jpeg'
} else {
const messageContent = message[type] as
| WAMessageProto.VideoMessage
| WAMessageProto.ImageMessage
| WAMessageProto.AudioMessage
| WAMessageProto.DocumentMessage
| WAProto.VideoMessage
| WAProto.ImageMessage
| WAProto.AudioMessage
| WAProto.DocumentMessage
extension = getExtension (messageContent.mimetype)
}
return extension

View File

@@ -1,6 +1,6 @@
import { Boom } from '@hapi/boom'
import { createReadStream, promises as fs } from "fs"
import { proto } from '../../WAMessage'
import { proto } from '../../WAProto'
import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from "../Defaults"
import {
AnyMediaMessageContent,
@@ -13,7 +13,7 @@ import {
WAMediaUpload,
WAMessage,
WAMessageContent,
WAMessageProto,
WAProto,
WATextMessage,
MediaType,
WAMessageStatus
@@ -38,14 +38,15 @@ const MIMETYPE_MAP: { [T in MediaType]: string } = {
document: 'application/pdf',
audio: 'audio/ogg; codecs=opus',
sticker: 'image/webp',
history: 'application/x-protobuf'
}
const MessageTypeProto = {
'image': WAMessageProto.ImageMessage,
'video': WAMessageProto.VideoMessage,
'audio': WAMessageProto.AudioMessage,
'sticker': WAMessageProto.StickerMessage,
'document': WAMessageProto.DocumentMessage,
'image': WAProto.ImageMessage,
'video': WAProto.VideoMessage,
'audio': WAProto.AudioMessage,
'sticker': WAProto.StickerMessage,
'document': WAProto.DocumentMessage,
} as const
const ButtonType = proto.ButtonsMessage.ButtonsMessageHeaderType
@@ -69,7 +70,7 @@ export const prepareWAMessageMedia = async(
if(typeof uploadData.media === 'object' && 'url' in uploadData.media) {
const result = !!options.mediaCache && await options.mediaCache!(uploadData.media.url?.toString())
if(result) {
return WAMessageProto.Message.fromObject({
return WAProto.Message.fromObject({
[`${mediaType}Message`]: result
})
}
@@ -136,7 +137,7 @@ export const prepareWAMessageMedia = async(
}
)
}
return WAMessageProto.Message.fromObject(content)
return WAProto.Message.fromObject(content)
}
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
ephemeralExpiration = ephemeralExpiration || 0
@@ -144,13 +145,13 @@ export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: n
ephemeralMessage: {
message: {
protocolMessage: {
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
type: WAProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration
}
}
}
}
return WAMessageProto.Message.fromObject(content)
return WAProto.Message.fromObject(content)
}
/**
* Generate forwarded message content like WA does
@@ -207,14 +208,14 @@ export const generateWAMessageContent = async(
throw new Boom('require atleast 1 contact', { statusCode: 400 })
}
if(contactLen === 1) {
m.contactMessage = WAMessageProto.ContactMessage.fromObject(message.contacts.contacts[0])
m.contactMessage = WAProto.ContactMessage.fromObject(message.contacts.contacts[0])
}
} else if('location' in message) {
m.locationMessage = WAMessageProto.LocationMessage.fromObject(message.location)
m.locationMessage = WAProto.LocationMessage.fromObject(message.location)
} else if('delete' in message) {
m.protocolMessage = {
key: message.delete,
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE
type: WAProto.ProtocolMessage.ProtocolMessageType.REVOKE
}
} else if('forward' in message) {
m = generateForwardMessageContent(
@@ -259,7 +260,7 @@ export const generateWAMessageContent = async(
m[messageType].contextInfo = m[messageType] || { }
m[messageType].contextInfo.mentionedJid = message.mentions
}
return WAMessageProto.Message.fromObject(m)
return WAProto.Message.fromObject(m)
}
export const generateWAMessageFromContent = (
jid: string,
@@ -290,7 +291,7 @@ export const generateWAMessageFromContent = (
}
if(
// if we want to send a disappearing message
!!options?.ephemeralOptions &&
!!options?.ephemeralExpiration &&
// and it's not a protocol message -- delete, toggle disappear message
key !== 'protocolMessage' &&
// already not converted to disappearing message
@@ -298,8 +299,8 @@ export const generateWAMessageFromContent = (
) {
message[key].contextInfo = {
...(message[key].contextInfo || {}),
expiration: options.ephemeralOptions.expiration || WA_DEFAULT_EPHEMERAL,
ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL,
//ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
}
message = {
ephemeralMessage: {
@@ -307,7 +308,7 @@ export const generateWAMessageFromContent = (
}
}
}
message = WAMessageProto.Message.fromObject (message)
message = WAProto.Message.fromObject (message)
const messageJSON = {
key: {
@@ -321,7 +322,7 @@ export const generateWAMessageFromContent = (
participant: jid.includes('@g.us') ? userJid : undefined,
status: WAMessageStatus.PENDING
}
return WAMessageProto.WebMessageInfo.fromObject (messageJSON)
return WAProto.WebMessageInfo.fromObject (messageJSON)
}
export const generateWAMessage = async(
jid: string,

167
src/Utils/noise-handler.ts Normal file
View File

@@ -0,0 +1,167 @@
import { sha256, generateSharedKey, hkdf } from "./generics";
import { Binary } from "../WABinary";
import { createCipheriv, createDecipheriv } from "crypto";
import { NOISE_MODE, NOISE_WA_HEADER } from "../Defaults";
import { KeyPair } from "../Types";
import { BinaryNode, decodeBinaryNode } from "../WABinary";
import { Boom } from "@hapi/boom";
import { proto } from '../../WAProto'
const generateIV = (counter: number) => {
const iv = new ArrayBuffer(12);
new DataView(iv).setUint32(8, counter);
return new Uint8Array(iv)
}
export default ({ public: publicKey, private: privateKey }: KeyPair) => {
const authenticate = (data: Uint8Array) => {
if(!isFinished) {
hash = sha256( Buffer.from(Binary.build(hash, data).readByteArray()) )
}
}
const encrypt = (plaintext: Uint8Array) => {
const authTagLength = 128 >> 3
const cipher = createCipheriv('aes-256-gcm', encKey, generateIV(writeCounter), { authTagLength })
cipher.setAAD(hash)
const result = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()])
writeCounter += 1
authenticate(result)
return result
}
const decrypt = (ciphertext: Uint8Array) => {
// before the handshake is finished, we use the same counter
// after handshake, the counters are different
const iv = generateIV(isFinished ? readCounter : writeCounter)
const cipher = createDecipheriv('aes-256-gcm', decKey, iv)
// decrypt additional adata
const tagLength = 128 >> 3
const enc = ciphertext.slice(0, ciphertext.length-tagLength)
const tag = ciphertext.slice(ciphertext.length-tagLength)
// set additional data
cipher.setAAD(hash)
cipher.setAuthTag(tag)
const result = Buffer.concat([cipher.update(enc), cipher.final()])
if(isFinished) readCounter += 1
else writeCounter += 1
authenticate(ciphertext)
return result
}
const localHKDF = (data: Uint8Array) => {
const key = hkdf(Buffer.from(data), 64, { salt, info: '' })
return [key.slice(0, 32), key.slice(32)]
}
const mixIntoKey = (data: Uint8Array) => {
const [write, read] = localHKDF(data)
salt = write
encKey = read
decKey = read
readCounter = 0
writeCounter = 0
}
const finishInit = () => {
const [write, read] = localHKDF(new Uint8Array(0))
encKey = write
decKey = read
hash = Buffer.from([])
readCounter = 0
writeCounter = 0
isFinished = true
}
const data = Binary.build(NOISE_MODE).readBuffer()
let hash = Buffer.from(data.byteLength === 32 ? data : sha256(Buffer.from(data)))
let salt = hash
let encKey = hash
let decKey = hash
let readCounter = 0
let writeCounter = 0
let isFinished = false
let sentIntro = false
const outBinary = new Binary()
const inBinary = new Binary()
authenticate(NOISE_WA_HEADER)
authenticate(publicKey)
return {
encrypt,
decrypt,
authenticate,
mixIntoKey,
finishInit,
processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => {
authenticate(serverHello!.ephemeral!)
mixIntoKey(generateSharedKey(privateKey, serverHello.ephemeral!))
const decStaticContent = decrypt(serverHello!.static!)
mixIntoKey(generateSharedKey(privateKey, decStaticContent))
const certDecoded = decrypt(serverHello!.payload!)
const { details: certDetails, signature: certSignature } = proto.NoiseCertificate.decode(certDecoded)
const { issuer: certIssuer, key: certKey } = proto.Details.decode(certDetails)
if(Buffer.compare(decStaticContent, certKey) !== 0) {
throw new Boom('certification match failed', { statusCode: 400 })
}
const keyEnc = encrypt(noiseKey.public)
mixIntoKey(generateSharedKey(noiseKey.private, serverHello!.ephemeral!))
return keyEnc
},
encodeFrame: (data: Buffer | Uint8Array) => {
if(isFinished) {
data = encrypt(data)
}
const introSize = sentIntro ? 0 : NOISE_WA_HEADER.length
outBinary.ensureAdditionalCapacity(introSize + 3 + data.byteLength)
if (!sentIntro) {
outBinary.writeByteArray(NOISE_WA_HEADER)
sentIntro = true
}
outBinary.writeUint8(data.byteLength >> 16)
outBinary.writeUint16(65535 & data.byteLength)
outBinary.write(data)
const bytes = outBinary.readByteArray()
return bytes as Uint8Array
},
decodeFrame: (newData: Buffer | Uint8Array, onFrame: (buff: Uint8Array | BinaryNode) => void) => {
// the binary protocol uses its own framing mechanism
// on top of the WS frames
// so we get this data and separate out the frames
const getBytesSize = () => {
return (inBinary.readUint8() << 16) | inBinary.readUint16()
}
const peekSize = () => {
return !(inBinary.size() < 3) && getBytesSize() <= inBinary.size()
}
inBinary.writeByteArray(newData)
while(inBinary.peek(peekSize)) {
const bytes = getBytesSize()
let frame: Uint8Array | BinaryNode = inBinary.readByteArray(bytes)
if(isFinished) {
const result = decrypt(frame as Uint8Array)
const unpacked = new Binary(result).decompressed()
frame = decodeBinaryNode(unpacked)
}
onFrame(frame)
}
inBinary.peek(peekSize)
}
}
}

253
src/Utils/signal.ts Normal file
View File

@@ -0,0 +1,253 @@
import * as libsignal from 'libsignal'
import { encodeBigEndian, generateCurveKeyPair } from "./generics"
import { SenderKeyDistributionMessage, GroupSessionBuilder, SenderKeyRecord, SenderKeyName, GroupCipher } from '../../WASignalGroup'
import { SignalIdentity, SignalKeyStore, SignedKeyPair, KeyPair, AuthenticationState } from "../Types/Auth"
import { assertNodeErrorFree, BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildUInt, jidDecode } from "../WABinary"
import { proto } from "../../WAProto"
export const generateSignalPubKey = (pubKey: Uint8Array | Buffer) => {
const newPub = Buffer.alloc(33)
newPub.set([5], 0)
newPub.set(pubKey, 1)
return newPub
}
const jidToSignalAddress = (jid: string) => jid.split('@')[0]
export const jidToSignalProtocolAddress = (jid: string) => {
return new libsignal.ProtocolAddress(jidToSignalAddress(jid), 0)
}
export const jidToSignalSenderKeyName = (group: string, user: string): string => {
return new SenderKeyName(group, jidToSignalProtocolAddress(user)).toString()
}
export const createSignalIdentity = (
wid: string,
accountSignatureKey: Uint8Array
): SignalIdentity => {
return {
identifier: { name: wid, deviceId: 0 },
identifierKey: generateSignalPubKey(accountSignatureKey)
}
}
export const getPreKeys = async({ getPreKey }: SignalKeyStore, min: number, limit: number) => {
const dict: { [id: number]: KeyPair } = { }
for(let id = min; id < limit;id++) {
const key = await getPreKey(id)
if(key) dict[+id] = key
}
return dict
}
export const generateOrGetPreKeys = ({ creds }: AuthenticationState, range: number) => {
const avaliable = creds.nextPreKeyId - creds.firstUnuploadedPreKeyId
const remaining = range - avaliable
const lastPreKeyId = creds.nextPreKeyId + remaining - 1
const newPreKeys: { [id: number]: KeyPair } = { }
if(remaining > 0) {
for(let i = creds.nextPreKeyId;i <= lastPreKeyId;i++) {
newPreKeys[i] = generateCurveKeyPair()
}
}
return {
newPreKeys,
lastPreKeyId,
preKeysRange: [creds.firstUnuploadedPreKeyId, range] as const,
}
}
export const xmppSignedPreKey = (key: SignedKeyPair): BinaryNode => (
{
tag: 'skey',
attrs: { },
content: [
{ tag: 'id', attrs: { }, content: encodeBigEndian(key.keyId, 3) },
{ tag: 'value', attrs: { }, content: key.keyPair.public },
{ tag: 'signature', attrs: { }, content: key.signature }
]
}
)
export const xmppPreKey = (pair: KeyPair, id: number): BinaryNode => (
{
tag: 'key',
attrs: { },
content: [
{ tag: 'id', attrs: { }, content: encodeBigEndian(id, 3) },
{ tag: 'value', attrs: { }, content: pair.public }
]
}
)
export const signalStorage = ({ creds, keys }: AuthenticationState) => ({
loadSession: async id => {
const sess = await keys.getSession(id)
if(sess) {
return libsignal.SessionRecord.deserialize(sess)
}
},
storeSession: async(id, session) => {
await keys.setSession(id, session.serialize())
},
isTrustedIdentity: () => {
return true
},
loadPreKey: async(id: number) => {
const key = await keys.getPreKey(id)
if(key) {
return {
privKey: Buffer.from(key.private),
pubKey: Buffer.from(key.public)
}
}
},
removePreKey: (id: number) => keys.setPreKey(id, null),
loadSignedPreKey: (keyId: number) => {
const key = creds.signedPreKey
return {
privKey: Buffer.from(key.keyPair.private),
pubKey: Buffer.from(key.keyPair.public)
}
},
loadSenderKey: async(keyId) => {
const key = await keys.getSenderKey(keyId)
if(key) return new SenderKeyRecord(key)
},
storeSenderKey: async(keyId, key) => {
await keys.setSenderKey(keyId, key.serialize())
},
getOurRegistrationId: () => (
creds.registrationId
),
getOurIdentity: () => {
const { signedIdentityKey } = creds
return {
privKey: Buffer.from(signedIdentityKey.private),
pubKey: generateSignalPubKey(signedIdentityKey.public),
}
}
})
export const decryptGroupSignalProto = (group: string, user: string, msg: Buffer | Uint8Array, auth: AuthenticationState) => {
const senderName = jidToSignalSenderKeyName(group, user)
const cipher = new GroupCipher(signalStorage(auth), senderName)
return cipher.decrypt(Buffer.from(msg))
}
export const processSenderKeyMessage = async(
authorJid: string,
item: proto.ISenderKeyDistributionMessage,
auth: AuthenticationState
) => {
const builder = new GroupSessionBuilder(signalStorage(auth))
const senderName = jidToSignalSenderKeyName(item.groupId, authorJid)
const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage)
const senderKey = await auth.keys.getSenderKey(senderName)
if(!senderKey) {
const record = new SenderKeyRecord()
await auth.keys.setSenderKey(senderName, record)
}
await builder.process(senderName, senderMsg)
}
export const decryptSignalProto = async(user: string, type: 'pkmsg' | 'msg', msg: Buffer | Uint8Array, auth: AuthenticationState) => {
const addr = jidToSignalProtocolAddress(user)
const session = new libsignal.SessionCipher(signalStorage(auth), addr)
let result: Buffer
switch(type) {
case 'pkmsg':
result = await session.decryptPreKeyWhisperMessage(msg)
break
case 'msg':
result = await session.decryptWhisperMessage(msg)
break
}
return result
}
export const encryptSignalProto = async(user: string, buffer: Buffer, auth: AuthenticationState) => {
const addr = jidToSignalProtocolAddress(user)
const cipher = new libsignal.SessionCipher(signalStorage(auth), addr)
const { type, body } = await cipher.encrypt(buffer)
return {
type: type === 3 ? 'pkmsg' : 'msg',
ciphertext: Buffer.from(body, 'binary')
}
}
export const encryptSenderKeyMsgSignalProto = async(group: string, data: Uint8Array | Buffer, auth: AuthenticationState) => {
const storage = signalStorage(auth)
const senderName = jidToSignalSenderKeyName(group, auth.creds.me!.id)
const builder = new GroupSessionBuilder(storage)
const senderKey = await auth.keys.getSenderKey(senderName)
if(!senderKey) {
const record = new SenderKeyRecord()
await auth.keys.setSenderKey(senderName, record)
}
const senderKeyDistributionMessage = await builder.create(senderName)
const session = new GroupCipher(storage, senderName)
return {
ciphertext: await session.encrypt(data) as Uint8Array,
senderKeyDistributionMessageKey: senderKeyDistributionMessage.serialize() as Buffer,
}
}
export const parseAndInjectE2ESession = async(node: BinaryNode, auth: AuthenticationState) => {
const extractKey = (key: BinaryNode) => (
key ? ({
keyId: getBinaryNodeChildUInt(key, 'id', 3),
publicKey: generateSignalPubKey(
getBinaryNodeChildBuffer(key, 'value')
),
signature: getBinaryNodeChildBuffer(key, 'signature'),
}) : undefined
)
node = getBinaryNodeChild(getBinaryNodeChild(node, 'list'), 'user')
assertNodeErrorFree(node)
const signedKey = getBinaryNodeChild(node, 'skey')
const key = getBinaryNodeChild(node, 'key')
const identity = getBinaryNodeChildBuffer(node, 'identity')
const jid = node.attrs.jid
const registrationId = getBinaryNodeChildUInt(node, 'registration', 4)
const device = {
registrationId,
identityKey: generateSignalPubKey(identity),
signedPreKey: extractKey(signedKey),
preKey: extractKey(key)
}
const cipher = new libsignal.SessionBuilder(signalStorage(auth), jidToSignalProtocolAddress(jid))
await cipher.initOutgoing(device)
}
export const extractDeviceJids = (result: BinaryNode) => {
const extracted: { user: string, device?: number, agent?: number }[] = []
for(const node of result.content as BinaryNode[]) {
const list = getBinaryNodeChild(node, 'list')?.content
if(list && Array.isArray(list)) {
for(const item of list) {
const { user } = jidDecode(item.attrs.jid)
const devicesNode = getBinaryNodeChild(item, 'devices')
const deviceListNode = getBinaryNodeChild(devicesNode, 'device-list')
if(Array.isArray(deviceListNode?.content)) {
for(const { tag, attrs } of deviceListNode!.content) {
if(tag === 'device') {
extracted.push({ user, device: +attrs.id })
}
}
}
}
}
}
return extracted
}

View File

@@ -1,116 +1,204 @@
import {Boom} from '@hapi/boom'
import * as Curve from 'curve25519-js'
import type { Contact } from '../Types/Contact'
import type { AnyAuthenticationCredentials, AuthenticationCredentials, AuthenticationCredentialsBase64, CurveKeyPair } from "../Types"
import { aesDecrypt, hkdf, hmacSign, whatsappID } from './generics'
import { readFileSync } from 'fs'
import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto'
import { proto } from '../../WAProto'
import type { AuthenticationState, SocketConfig, SignalKeyStore, AuthenticationCreds, KeyPair } from "../Types"
import { curveSign, hmacSign, curveVerify, encodeInt, generateCurveKeyPair, generateRegistrationId, signedKeyPair } from './generics'
import { BinaryNode, S_WHATSAPP_NET, jidDecode, Binary } from '../WABinary'
import { createSignalIdentity } from './signal'
export const normalizedAuthInfo = (authInfo: AnyAuthenticationCredentials | string) => {
if (!authInfo) return
if (typeof authInfo === 'string') {
const file = readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
authInfo = JSON.parse(file) as AnyAuthenticationCredentials
}
if ('clientID' in authInfo) {
authInfo = {
clientID: authInfo.clientID,
serverToken: authInfo.serverToken,
clientToken: authInfo.clientToken,
encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'),
macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'),
}
} else {
const secretBundle: {encKey: string, macKey: string} = typeof authInfo.WASecretBundle === 'string' ? JSON.parse (authInfo.WASecretBundle): authInfo.WASecretBundle
authInfo = {
clientID: authInfo.WABrowserId.replace(/\"/g, ''),
serverToken: authInfo.WAToken2.replace(/\"/g, ''),
clientToken: authInfo.WAToken1.replace(/\"/g, ''),
encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64
macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64
}
}
return authInfo as AuthenticationCredentials
const ENCODED_VERSION = 'S9Kdc4pc4EJryo21snc5cg=='
const getUserAgent = ({ version, browser }: Pick<SocketConfig, 'version' | 'browser'>) => ({
appVersion: {
primary: version[0],
secondary: version[1],
tertiary: version[2],
},
platform: 14,
releaseChannel: 0,
mcc: "000",
mnc: "000",
osVersion: browser[2],
manufacturer: "",
device: browser[1],
osBuildNumber: "0.1",
localeLanguageIso6391: 'en',
localeCountryIso31661Alpha2: 'en',
})
export const generateLoginNode = (userJid: string, config: Pick<SocketConfig, 'version' | 'browser'>) => {
const { user, device } = jidDecode(userJid)
const payload = {
passive: true,
connectType: 1,
connectReason: 1,
userAgent: getUserAgent(config),
webInfo: { webSubPlatform: 0 },
username: parseInt(user, 10),
device: device,
}
return proto.ClientPayload.encode(payload).finish()
}
export const base64EncodedAuthenticationCredentials = (creds: AnyAuthenticationCredentials) => {
const normalized = normalizedAuthInfo(creds)
return {
...normalized,
encKey: normalized.encKey.toString('base64'),
macKey: normalized.macKey.toString('base64')
} as AuthenticationCredentialsBase64
}
/**
* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in
* @private
* @param json
*/
export const validateNewConnection = (
json: { [_: string]: any },
auth: AuthenticationCredentials,
curveKeys: CurveKeyPair
export const generateRegistrationNode = (
{ registrationId, signedPreKey, signedIdentityKey }: Pick<AuthenticationCreds, 'registrationId' | 'signedPreKey' | 'signedIdentityKey'>,
config: Pick<SocketConfig, 'version' | 'browser'>
) => {
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
const onValidationSuccess = () => {
const user: Contact = {
jid: whatsappID(json.wid),
name: json.pushname
}
return { user, auth, phone: json.phone }
}
if (!json.secret) {
// if we didn't get a secret, we don't need it, we're validated
if (json.clientToken && json.clientToken !== auth.clientToken) {
auth = { ...auth, clientToken: json.clientToken }
}
if (json.serverToken && json.serverToken !== auth.serverToken) {
auth = { ...auth, serverToken: json.serverToken }
}
return onValidationSuccess()
}
const secret = Buffer.from(json.secret, 'base64')
if (secret.length !== 144) {
throw new Error ('incorrect secret length received: ' + secret.length)
}
const appVersionBuf = new Uint8Array(Buffer.from(ENCODED_VERSION, "base64"));
// generate shared key from our private key & the secret shared by the server
const sharedKey = Curve.sharedKey(curveKeys.private, secret.slice(0, 32))
// expand the key to 80 bytes using HKDF
const expandedKey = hkdf(sharedKey as Buffer, 80)
const companion = {
os: config.browser[0],
version: {
primary: 10,
secondary: undefined,
tertiary: undefined,
},
platformType: 1,
requireFullSync: false,
};
// perform HMAC validation.
const hmacValidationKey = expandedKey.slice(32, 64)
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
const companionProto = proto.CompanionProps.encode(companion).finish()
const hmac = hmacSign(hmacValidationMessage, hmacValidationKey)
const registerPayload = {
connectReason: 1,
connectType: 1,
passive: false,
regData: {
buildHash: appVersionBuf,
companionProps: companionProto,
eRegid: encodeInt(4, registrationId),
eKeytype: encodeInt(1, 5),
eIdent: signedIdentityKey.public,
eSkeyId: encodeInt(3, signedPreKey.keyId),
eSkeyVal: signedPreKey.keyPair.public,
eSkeySig: signedPreKey.signature,
},
userAgent: getUserAgent(config),
webInfo: {
webSubPlatform: 0,
},
}
if (!hmac.equals(secret.slice(32, 64))) {
// if the checksums didn't match
throw new Boom('HMAC validation failed', { statusCode: 400 })
}
// computed HMAC should equal secret[32:64]
// expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp
// they are encrypted using key: expandedKey[0:32]
const encryptedAESKeys = Buffer.concat([
expandedKey.slice(64, expandedKey.length),
secret.slice(64, secret.length),
])
const decryptedKeys = aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32))
// set the credentials
auth = {
encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages
macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages
clientToken: json.clientToken,
serverToken: json.serverToken,
clientID: auth.clientID,
}
return onValidationSuccess()
return proto.ClientPayload.encode(registerPayload).finish()
}
export const computeChallengeResponse = (challenge: string, auth: AuthenticationCredentials) => {
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string
const signed = hmacSign(bytes, auth.macKey).toString('base64') // sign the challenge string with our macKey
return[ 'admin', 'challenge', signed, auth.serverToken, auth.clientID] // prepare to send this signed string with the serverToken & clientID
export const initInMemoryKeyStore = (
{ preKeys, sessions, senderKeys }: {
preKeys?: { [k: number]: KeyPair },
sessions?: { [k: string]: any },
senderKeys?: { [k: string]: any }
} = { },
) => {
preKeys = preKeys || { }
sessions = sessions || { }
senderKeys = senderKeys || { }
return {
preKeys,
sessions,
senderKeys,
getPreKey: keyId => preKeys[keyId],
setPreKey: (keyId, pair) => {
if(pair) preKeys[keyId] = pair
else delete preKeys[keyId]
},
getSession: id => sessions[id],
setSession: (id, item) => {
if(item) sessions[id] = item
else delete sessions[id]
},
getSenderKey: id => {
return senderKeys[id]
},
setSenderKey: (id, item) => {
if(item) senderKeys[id] = item
else delete senderKeys[id]
}
} as SignalKeyStore
}
export const initAuthState = (): AuthenticationState => {
const identityKey = generateCurveKeyPair()
return {
creds: {
noiseKey: generateCurveKeyPair(),
signedIdentityKey: identityKey,
signedPreKey: signedKeyPair(identityKey, 1),
registrationId: generateRegistrationId(),
advSecretKey: randomBytes(32).toString('base64'),
nextPreKeyId: 1,
firstUnuploadedPreKeyId: 1,
serverHasPreKeys: false
},
keys: initInMemoryKeyStore()
}
}
export const configureSuccessfulPairing = (
stanza: BinaryNode,
{ advSecretKey, signedIdentityKey, signalIdentities }: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
) => {
const pair = stanza.content[0] as BinaryNode
const pairContent = Array.isArray(pair.content) ? pair.content : []
const msgId = stanza.attrs.id
const deviceIdentity = pairContent.find(m => m.tag === 'device-identity')?.content
const businessName = pairContent.find(m => m.tag === 'biz')?.attrs?.name
const verifiedName = businessName || ''
const jid = pairContent.find(m => m.tag === 'device')?.attrs?.jid
const { details, hmac } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentity as Buffer)
const advSign = hmacSign(details, Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac, advSign) !== 0) {
throw new Boom('Invalid pairing')
}
const account = proto.ADVSignedDeviceIdentity.decode(details)
const { accountSignatureKey, accountSignature } = account
const accountMsg = Binary.build(new Uint8Array([6, 0]), account.details, signedIdentityKey.public).readByteArray()
if (!curveVerify(accountSignatureKey, accountMsg, accountSignature)) {
throw new Boom('Failed to verify account signature')
}
const deviceMsg = Binary.build(new Uint8Array([6, 1]), account.details, signedIdentityKey.public, account.accountSignatureKey).readByteArray()
account.deviceSignature = curveSign(signedIdentityKey.private, deviceMsg)
const identity = createSignalIdentity(jid, accountSignatureKey)
const keyIndex = proto.ADVDeviceIdentity.decode(account.details).keyIndex
const accountEnc = proto.ADVSignedDeviceIdentity.encode({
...account.toJSON(),
accountSignatureKey: undefined
}).finish()
const reply: BinaryNode = {
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'result',
id: msgId,
},
content: [
{
tag: 'pair-device-sign',
attrs: { },
content: [
{ tag: 'device-identity', attrs: { 'key-index': `${keyIndex}` }, content: accountEnc }
]
}
]
}
const authUpdate: Partial<AuthenticationCreds> = {
account,
me: { id: jid, verifiedName },
signalIdentities: [...(signalIdentities || []), identity]
}
return {
creds: authUpdate,
reply
}
}