mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
finalize multi-device
This commit is contained in:
198
src/Utils/chat-utils.ts
Normal file
198
src/Utils/chat-utils.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
25
src/Utils/history.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
167
src/Utils/noise-handler.ts
Normal 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
253
src/Utils/signal.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user