Moved to src

This commit is contained in:
Adhiraj Singh
2020-07-01 18:14:47 +05:30
parent 7a2657a4ad
commit 1f7e9c8142
22 changed files with 8 additions and 8 deletions

265
src/WAConnection/Base.ts Normal file
View File

@@ -0,0 +1,265 @@
import * as QR from 'qrcode-terminal'
import * as fs from 'fs'
import WS from 'ws'
import * as Utils from './Utils'
import Encoder from '../Binary/Encoder'
import Decoder from '../Binary/Decoder'
import { AuthenticationCredentials, UserMetaData, WANode, AuthenticationCredentialsBase64, WATag, MessageLogLevel } from './Constants'
/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */
const generateQRCode = function ([ref, publicKey, clientID]) {
const str = ref + ',' + publicKey + ',' + clientID
QR.generate(str, { small: true })
}
export default class WAConnectionBase {
/** The version of WhatsApp Web we're telling the servers we are */
version: [number, number, number] = [2, 2025, 6]
/** The Browser we're telling the WhatsApp Web servers we are */
browserDescription: [string, string] = ['Baileys', 'Baileys']
/** Metadata like WhatsApp id, name set on WhatsApp etc. */
userMetaData: UserMetaData = { id: null, name: null, phone: null }
/** Should reconnect automatically after an unexpected disconnect */
autoReconnect = true
lastSeen: Date = null
/** Log messages that are not handled, so you can debug & see what custom stuff you can implement */
logLevel: MessageLogLevel = MessageLogLevel.none
/** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */
protected authInfo: AuthenticationCredentials = {
clientID: null,
serverToken: null,
clientToken: null,
encKey: null,
macKey: null,
}
/** Curve keys to initially authenticate */
protected curveKeys: { private: Uint8Array; public: Uint8Array }
/** The websocket connection */
protected conn: WS = null
protected msgCount = 0
protected keepAliveReq: NodeJS.Timeout
protected callbacks = {}
protected encoder = new Encoder()
protected decoder = new Decoder()
/**
* What to do when you need the phone to authenticate the connection (generate QR code by default)
*/
onReadyForPhoneAuthentication = generateQRCode
unexpectedDisconnect = (err) => this.close()
/**
* base 64 encode the authentication credentials and return them
* these can then be used to login again by passing the object to the connect () function.
* @see connect () in WhatsAppWeb.Session
*/
base64EncodedAuthInfo() {
return {
clientID: this.authInfo.clientID,
serverToken: this.authInfo.serverToken,
clientToken: this.authInfo.clientToken,
encKey: this.authInfo.encKey.toString('base64'),
macKey: this.authInfo.macKey.toString('base64'),
}
}
/**
* Load in the authentication credentials
* @param authInfo the authentication credentials or path to auth credentials JSON
*/
loadAuthInfoFromBase64(authInfo: AuthenticationCredentialsBase64 | string) {
if (!authInfo) {
throw 'given authInfo is null'
}
if (typeof authInfo === 'string') {
this.log(`loading authentication credentials from ${authInfo}`)
const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
authInfo = JSON.parse(file) as AuthenticationCredentialsBase64
}
this.authInfo = {
clientID: authInfo.clientID,
serverToken: authInfo.serverToken,
clientToken: authInfo.clientToken,
encKey: Buffer.from(authInfo.encKey, 'base64'), // decode from base64
macKey: Buffer.from(authInfo.macKey, 'base64'), // decode from base64
}
}
/**
* Register for a callback for a certain function, will cancel automatically after one execution
* @param {[string, object, string] | string} parameters name of the function along with some optional specific parameters
*/
async registerCallbackOneTime(parameters) {
const json = await new Promise((resolve, _) => this.registerCallback(parameters, resolve))
this.deregisterCallback(parameters)
return json
}
/**
* Register for a callback for a certain function
* @param parameters name of the function along with some optional specific parameters
*/
registerCallback(parameters: [string, string?, string?] | string, callback) {
if (typeof parameters === 'string') {
return this.registerCallback([parameters, null, null], callback)
}
if (!Array.isArray(parameters)) {
throw 'parameters (' + parameters + ') must be a string or array'
}
const func = 'function:' + parameters[0]
const key = parameters[1] || ''
const key2 = parameters[2] || ''
if (!this.callbacks[func]) {
this.callbacks[func] = {}
}
if (!this.callbacks[func][key]) {
this.callbacks[func][key] = {}
}
this.callbacks[func][key][key2] = callback
}
/**
* Cancel all further callback events associated with the given parameters
* @param parameters name of the function along with some optional specific parameters
*/
deregisterCallback(parameters: [string, string?, string?] | string) {
if (typeof parameters === 'string') {
return this.deregisterCallback([parameters])
}
if (!Array.isArray(parameters)) {
throw 'parameters (' + parameters + ') must be a string or array'
}
const func = 'function:' + parameters[0]
const key = parameters[1] || ''
const key2 = parameters[2] || ''
if (this.callbacks[func] && this.callbacks[func][key] && this.callbacks[func][key][key2]) {
delete this.callbacks[func][key][key2]
return
}
this.log('WARNING: could not find ' + JSON.stringify(parameters) + ' to deregister')
}
/**
* Wait for a message with a certain tag to be received
* @param tag the message tag to await
* @param json query that was sent
* @param timeoutMs timeout after which the promise will reject
*/
async waitForMessage(tag: string, json: Object = null, timeoutMs: number = null) {
let promise = new Promise(
(resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }),
)
if (timeoutMs) {
promise = Utils.promiseTimeout(timeoutMs, promise).catch((err) => {
delete this.callbacks[tag]
throw err
})
}
return promise as Promise<any>
}
/**
* Query something from the WhatsApp servers and error on a non-200 status
* @param json the query itself
* @param [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary
* @param [timeoutMs] timeout after which the query will be failed (set to null to disable a timeout)
* @param [tag] the tag to attach to the message
* recieved JSON
*/
async queryExpecting200(
json: any[] | WANode,
binaryTags: WATag = null,
timeoutMs: number = null,
tag: string = null,
) {
return Utils.errorOnNon200Status(this.query(json, binaryTags, timeoutMs, tag))
}
/**
* Query something from the WhatsApp servers
* @param json the query itself
* @param [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary
* @param [timeoutMs] timeout after which the query will be failed (set to null to disable a timeout)
* @param [tag] the tag to attach to the message
* recieved JSON
*/
async query(json: any[] | WANode, binaryTags: WATag = null, timeoutMs: number = null, tag: string = null) {
if (binaryTags) {
tag = this.sendBinary(json as WANode, binaryTags, tag)
} else {
tag = this.sendJSON(json, tag)
}
return this.waitForMessage(tag, json, timeoutMs)
}
/**
* Send a binary encoded message
* @param json the message to encode & send
* @param {[number, number]} tags the binary tags to tell WhatsApp what the message is all about
* @param {string} [tag] the tag to attach to the message
* @return {string} the message tag
*/
private sendBinary(json: WANode, tags: [number, number], tag: string) {
const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format
let buff = Utils.aesEncrypt(binary, this.authInfo.encKey) // encrypt it using AES and our encKey
const sign = Utils.hmacSign(buff, this.authInfo.macKey) // sign the message using HMAC and our macKey
tag = tag || Utils.generateMessageTag()
buff = Buffer.concat([
Buffer.from(tag + ','), // generate & prefix the message tag
Buffer.from(tags), // prefix some bytes that tell whatsapp what the message is about
sign, // the HMAC sign of the message
buff, // the actual encrypted buffer
])
this.send(buff) // send it off
return tag
}
/**
* Send a plain JSON message to the WhatsApp servers
* @private
* @param json the message to send
* @param [tag] the tag to attach to the message
* @return the message tag
*/
private sendJSON(json: any[] | WANode, tag: string = null) {
tag = tag || Utils.generateMessageTag()
this.send(tag + ',' + JSON.stringify(json))
return tag
}
/** Send some message to the WhatsApp servers */
protected send(m) {
if (!this.conn) {
throw 'cannot send message, disconnected from WhatsApp'
}
this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages
this.conn.send(m)
}
/**
* Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request.
* @see close() if you just want to close the connection
*/
async logout() {
if (!this.conn) {
throw "You're not even connected, you can't log out"
}
await new Promise((resolve) => {
this.conn.send('goodbye,["admin","Conn","disconnect"]', null, () => {
this.authInfo = null
resolve()
})
})
this.close()
}
/** Close the connection to WhatsApp Web */
close() {
this.msgCount = 0
if (this.conn) {
this.conn.close()
this.conn = null
}
const keys = Object.keys(this.callbacks)
keys.forEach((key) => {
if (!key.includes('function:')) {
this.callbacks[key].errCallback('connection closed')
delete this.callbacks[key]
}
})
if (this.keepAliveReq) {
clearInterval(this.keepAliveReq)
}
}
protected log(text) {
console.log(`[Baileys][${new Date().toLocaleString()}] ${text}`)
}
}

257
src/WAConnection/Connect.ts Normal file
View File

@@ -0,0 +1,257 @@
import WS from 'ws'
import * as Utils from './Utils'
import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact, MessageLogLevel } from './Constants'
import WAConnectionValidator from './Validation'
export default class WAConnectionConnector extends WAConnectionValidator {
/**
* Connect to WhatsAppWeb
* @param [authInfo] credentials or path to credentials to log back in
* @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
* @return returns [userMetaData, chats, contacts, unreadMessages]
*/
async connect(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
const userInfo = await this.connectSlim(authInfo, timeoutMs)
const chats = await this.receiveChatsAndContacts(timeoutMs)
return [userInfo, ...chats] as [UserMetaData, WAChat[], WAContact[], WAMessage[]]
}
/**
* Connect to WhatsAppWeb, resolves without waiting for chats & contacts
* @param [authInfo] credentials to log back in
* @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
* @return [userMetaData, chats, contacts, unreadMessages]
*/
async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
// if we're already connected, throw an error
if (this.conn) {
throw [1, 'already connected or connecting']
}
// set authentication credentials if required
try {
this.loadAuthInfoFromBase64(authInfo)
} catch {}
this.conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com' })
let promise: Promise<UserMetaData> = new Promise((resolve, reject) => {
this.conn.on('open', () => {
this.log('connected to WhatsApp Web, authenticating...')
// start sending keep alive requests (keeps the WebSocket alive & updates our last seen)
this.authenticate()
.then((user) => {
this.startKeepAliveRequest()
resolve(user)
})
.catch(reject)
})
this.conn.on('message', (m) => this.onMessageRecieved(m)) // in WhatsAppWeb.Recv.js
this.conn.on('error', (error) => {
// if there was an error in the WebSocket
this.close()
reject(error)
})
})
promise = Utils.promiseTimeout(timeoutMs, promise)
return promise.catch(err => {
this.close()
throw err
})
}
/**
* Sets up callbacks to receive chats, contacts & unread messages.
* Must be called immediately after connect
* @returns [chats, contacts, unreadMessages]
*/
async receiveChatsAndContacts(timeoutMs: number = null) {
let chats: Array<WAChat> = []
let contacts: Array<WAContact> = []
let unreadMessages: Array<WAMessage> = []
let unreadMap: Record<string, number> = {}
let receivedContacts = false
let receivedMessages = false
let convoResolve
this.log('waiting for chats & contacts') // wait for the message with chats
const waitForConvos = () =>
new Promise(resolve => {
convoResolve = () => {
// de-register the callbacks, so that they don't get called again
this.deregisterCallback(['action', 'add:last'])
this.deregisterCallback(['action', 'add:before'])
this.deregisterCallback(['action', 'add:unread'])
resolve()
}
const chatUpdate = json => {
receivedMessages = true
const isLast = json[1].last
json = json[2]
if (json) {
for (let k = json.length - 1; k >= 0; k--) {
const message = json[k][2]
const jid = message.key.remoteJid.replace('@s.whatsapp.net', '@c.us')
if (!message.key.fromMe && unreadMap[jid] > 0) {
// only forward if the message is from the sender
unreadMessages.push(message)
unreadMap[jid] -= 1 // reduce
}
}
}
if (isLast && receivedContacts) { // if received contacts before messages
convoResolve ()
}
}
// wait for actual messages to load, "last" is the most recent message, "before" contains prior messages
this.registerCallback(['action', 'add:last'], chatUpdate)
this.registerCallback(['action', 'add:before'], chatUpdate)
this.registerCallback(['action', 'add:unread'], chatUpdate)
})
const waitForChats = async () => {
const json = await this.registerCallbackOneTime(['response', 'type:chat'])
json[2].forEach(chat => {
chats.push(chat[1]) // chats data (log json to see what it looks like)
// store the number of unread messages for each sender
unreadMap[chat[1].jid] = chat[1].count
})
if (chats.length > 0) return waitForConvos()
}
const waitForContacts = async () => {
const json = await this.registerCallbackOneTime(['response', 'type:contacts'])
contacts = json[2].map(item => item[1])
receivedContacts = true
// if you receive contacts after messages
// should probably resolve the promise
if (receivedMessages) convoResolve()
}
// wait for the chats & contacts to load
const promise = Promise.all([waitForChats(), waitForContacts()])
await Utils.promiseTimeout (timeoutMs, promise)
return [chats, contacts, unreadMessages] as [WAChat[], WAContact[], WAMessage[]]
}
private onMessageRecieved(message) {
if (message[0] === '!') {
// when the first character in the message is an '!', the server is updating the last seen
const timestamp = message.slice(1, message.length)
this.lastSeen = new Date(parseInt(timestamp))
} else {
const commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message
if (commaIndex < 0) {
// if there was no comma, then this message must be not be valid
throw [2, 'invalid message', message]
}
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 = message.slice(0, commaIndex).toString()
if (data.length === 0) {
// got an empty message, usually get one after sending a query with the 128 tag
return
}
let json
if (data[0] === '[' || data[0] === '{') {
// if the first character is a "[", then the data must just be plain JSON array or object
json = JSON.parse(data) // parse the JSON
} else if (this.authInfo.macKey && this.authInfo.encKey) {
/*
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
*/
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 = Utils.hmacSign(data, this.authInfo.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 = Utils.aesDecrypt(data, this.authInfo.encKey) // decrypt using AES
json = this.decoder.read(decrypted) // decode the binary message into a JSON array
} else {
throw [7, "checksums don't match"]
}
} else {
// if we recieved a message that was encrypted but we don't have the keys, then there must be an error
throw [3, 'recieved encrypted message when auth creds not available', message]
}
if (this.logLevel === MessageLogLevel.all) {
this.log(messageTag + ', ' + JSON.stringify(json))
}
/*
Check if this is a response to a message we sent
*/
if (this.callbacks[messageTag]) {
const q = this.callbacks[messageTag]
q.callback(json)
delete this.callbacks[messageTag]
return
}
/*
Check if this is a response to a message we are expecting
*/
if (this.callbacks['function:' + json[0]]) {
const callbacks = this.callbacks['function:' + json[0]]
let callbacks2
let callback
for (const key in json[1] || {}) {
callbacks2 = callbacks[key + ':' + json[1][key]]
if (callbacks2) {
break
}
}
if (!callbacks2) {
for (const key in json[1] || {}) {
callbacks2 = callbacks[key]
if (callbacks2) {
break
}
}
}
if (!callbacks2) {
callbacks2 = callbacks['']
}
if (callbacks2) {
callback = callbacks2[json[2] && json[2][0][0]]
if (!callback) {
callback = callbacks2['']
}
}
if (callback) {
callback(json)
return
}
}
if (this.logLevel === MessageLogLevel.unhandled) {
this.log('[Unhandled] ' + messageTag + ', ' + JSON.stringify(json))
}
}
}
/** Send a keep alive request every X seconds, server updates & responds with last seen */
private startKeepAliveRequest() {
const refreshInterval = 20
this.keepAliveReq = setInterval(() => {
const diff = (new Date().getTime() - this.lastSeen.getTime()) / 1000
/*
check if it's been a suspicious amount of time since the server responded with our last seen
it could be that the network is down, or the phone got unpaired from our connection
*/
if (diff > refreshInterval + 5) {
this.close()
if (this.autoReconnect) {
// attempt reconnecting if the user wants us to
this.log('disconnected unexpectedly, reconnecting...')
const reconnectLoop = () => this.connect(null, 25 * 1000).catch(reconnectLoop)
reconnectLoop() // keep trying to connect
} else {
this.unexpectedDisconnect('lost connection unexpectedly')
}
} else {
// if its all good, send a keep alive request
this.send('?,,')
}
}, refreshInterval * 1000)
}
}

View File

@@ -0,0 +1,78 @@
import { WA } from '../Binary/Constants'
import { proto } from '../../WAMessage/WAMessage'
export enum MessageLogLevel {
none=0,
unhandled=1,
all=2
}
export interface AuthenticationCredentials {
clientID: string
serverToken: string
clientToken: string
encKey: Buffer
macKey: Buffer
}
export interface AuthenticationCredentialsBase64 {
clientID: string
serverToken: string
clientToken: string
encKey: string
macKey: string
}
export interface UserMetaData {
id: string
name: string
phone: string
}
export type WANode = WA.Node
export type WAMessage = proto.WebMessageInfo
export type WAMessageContent = proto.IMessage
export interface WAGroupCreateResponse {
status: number
gid?: string
participants?: { [key: string]: any }
}
export interface WAGroupMetadata {
id: string
owner: string
subject: string
creation: number
participants: [{ id: string; isAdmin: boolean; isSuperAdmin: boolean }]
}
export interface WAGroupModification {
status: number
participants?: { [key: string]: any }
}
export interface WAContact {
notify?: string
jid: string
name?: string
index?: string
short?: string
}
export interface WAChat {
t: string
count: string
spam: 'false' | 'true'
jid: string
modify_tag: string
}
export enum WAMetric {
liveLocation = 3,
group = 10,
message = 16,
queryLiveLocation = 33,
}
export enum WAFlag {
ignore = 1 << 7,
acknowledge = 1 << 6,
available = 1 << 5,
unavailable = 1 << 4,
expires = 1 << 3,
skipOffline = 1 << 2,
}
/** Tag used with binary queries */
export type WATag = [WAMetric, WAFlag]

57
src/WAConnection/Tests.ts Normal file
View File

@@ -0,0 +1,57 @@
import * as assert from 'assert'
import WAConnection from './WAConnection'
import { AuthenticationCredentialsBase64 } from './Constants'
describe('QR generation', () => {
it('should generate QR', async () => {
const conn = new WAConnection()
let calledQR = false
conn.onReadyForPhoneAuthentication = ([ref, curveKey, clientID]) => {
assert.ok(ref, 'ref nil')
assert.ok(curveKey, 'curve key nil')
assert.ok(clientID, 'client ID nil')
calledQR = true
}
await assert.rejects(async () => conn.connectSlim(null, 5000), 'should have failed connect')
assert.equal(calledQR, true, 'QR not called')
})
})
describe('Test Connect', () => {
let auth: AuthenticationCredentialsBase64
it('should connect', async () => {
console.log('please be ready to scan with your phone')
const conn = new WAConnection()
const user = await conn.connectSlim(null)
assert.ok(user)
assert.ok(user.id)
conn.close()
auth = conn.base64EncodedAuthInfo()
})
it('should reconnect', async () => {
const conn = new WAConnection()
const [user, chats, contacts, unread] = await conn.connect(auth, 20*1000)
assert.ok(user)
assert.ok(user.id)
assert.ok(chats)
if (chats.length > 0) {
assert.ok(chats[0].jid)
assert.ok(chats[0].count)
}
assert.ok(contacts)
if (contacts.length > 0) {
assert.ok(contacts[0].jid)
}
assert.ok(unread)
if (unread.length > 0) {
assert.ok(unread[0].key)
}
await conn.logout()
await assert.rejects(async () => conn.connectSlim(auth), 'reconnect should have failed')
})
})

71
src/WAConnection/Utils.ts Normal file
View File

@@ -0,0 +1,71 @@
import * as Crypto from 'crypto'
import HKDF from 'futoin-hkdf'
/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */
export function aesDecrypt(buffer: Buffer, key: Buffer) {
return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16))
}
/** decrypt AES 256 CBC */
export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
const aes = Crypto.createDecipheriv('aes-256-cbc', key, IV)
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) {
const IV = randomBytes(16)
const aes = Crypto.createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
}
// encrypt AES 256 CBC with a given IV
export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
const aes = Crypto.createCipheriv('aes-256-cbc', key, IV)
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 Crypto.createHmac('sha256', key).update(buffer).digest()
}
export function sha256(buffer: Buffer) {
return Crypto.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' })
}
// generate a buffer with random bytes of the specified length
export function randomBytes(length) {
return Crypto.randomBytes(length)
}
export function promiseTimeout<T>(ms: number, promise: Promise<T>) {
if (!ms) { return promise }
// Create a promise that rejects in <ms> milliseconds
const timeout = new Promise((_, reject) => {
const id = setTimeout(() => {
clearTimeout(id)
reject('Timed out')
}, ms)
})
return Promise.race([promise, timeout]) as Promise<T>
}
// whatsapp requires a message tag for every message, we just use the timestamp as one
export function generateMessageTag() {
return new Date().getTime().toString()
}
// generate a random 16 byte client ID
export function generateClientID() {
return randomBytes(16).toString('base64')
}
// generate a random 10 byte ID to attach to a message
export function generateMessageID() {
return randomBytes(10).toString('hex').toUpperCase()
}
export function errorOnNon200Status(p: Promise<any>) {
return p.then((json) => {
if (json.status && typeof json.status === 'number' && Math.floor(json.status / 100) !== 2) {
throw new Error(`Unexpected status code: ${json.status}`)
}
return json
})
}

View File

@@ -0,0 +1,164 @@
import * as Curve from 'curve25519-js'
import * as Utils from './Utils'
import WAConnectionBase from './Base'
export default class WAConnectionValidator extends WAConnectionBase {
/** Authenticate the connection */
protected async authenticate() {
if (!this.authInfo.clientID) {
// if no auth info is present, that is, a new session has to be established
// generate a client ID
this.authInfo = {
clientID: Utils.generateClientID(),
clientToken: null,
serverToken: null,
encKey: null,
macKey: null,
}
}
const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true]
return this.query(data)
.then((json) => {
// we're trying to establish a new connection or are trying to log in
switch (json.status) {
case 200: // all good and we can procede to generate a QR code for new connection, or can now login given present auth info
if (this.authInfo.encKey && this.authInfo.macKey) {
// if we have the info to restore a closed session
const data = [
'admin',
'login',
this.authInfo.clientToken,
this.authInfo.serverToken,
this.authInfo.clientID,
'takeover',
]
return this.query(data, null, null, 's1') // wait for response with tag "s1"
} else {
return this.generateKeysForAuth(json.ref)
}
default:
throw [json.status, 'unknown error', json]
}
})
.then((json) => {
switch (json.status) {
case 401: // if the phone was unpaired
throw [json.status, 'unpaired from phone', json]
case 429: // request to login was denied, don't know why it happens
throw [json.status, 'request denied, try reconnecting', json]
case 304: // request to generate a new key for a QR code was denied
throw [json.status, 'request for new key denied', json]
default:
break
}
if (json[1] && json[1].challenge) {
// if its a challenge request (we get it when logging in)
return this.respondToChallenge(json[1].challenge).then((json) => {
if (json.status !== 200) {
// throw an error if the challenge failed
throw [json.status, 'unknown error', json]
}
return this.waitForMessage('s2', []) // otherwise wait for the validation message
})
} else {
// otherwise just chain the promise further
return json
}
})
.then((json) => {
this.validateNewConnection(json[1]) // validate the connection
this.log('validated connection successfully')
this.lastSeen = new Date() // set last seen to right now
return this.userMetaData
})
}
/**
* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in
* @private
* @param {object} json
*/
private validateNewConnection(json) {
const onValidationSuccess = () => {
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
this.userMetaData = {
id: json.wid.replace('@c.us', '@s.whatsapp.net'),
name: json.pushname,
phone: json.phone,
}
return this.userMetaData
}
if (json.connected) {
// only if we're connected
if (!json.secret) {
// if we didn't get a secret, we don't need it, we're validated
return onValidationSuccess()
}
const secret = Buffer.from(json.secret, 'base64')
if (secret.length !== 144) {
throw [4, 'incorrect secret length: ' + secret.length]
}
// generate shared key from our private key & the secret shared by the server
const sharedKey = Curve.sharedKey(this.curveKeys.private, secret.slice(0, 32))
// expand the key to 80 bytes using HKDF
const expandedKey = Utils.hkdf(sharedKey as Buffer, 80)
// perform HMAC validation.
const hmacValidationKey = expandedKey.slice(32, 64)
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
const hmac = Utils.hmacSign(hmacValidationMessage, hmacValidationKey)
if (hmac.equals(secret.slice(32, 64))) {
// 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 = Utils.aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32))
// set the credentials
this.authInfo = {
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: this.authInfo.clientID,
}
return onValidationSuccess()
} else {
// if the checksums didn't match
throw [5, 'HMAC validation failed']
}
} else {
// if we didn't get the connected field (usually we get this message when one opens WhatsApp on their phone)
throw [6, 'json connection failed', json]
}
}
/**
* When logging back in (restoring a previously closed session), WhatsApp may challenge one to check if one still has the encryption keys
* WhatsApp does that by asking for us to sign a string it sends with our macKey
*/
protected respondToChallenge(challenge: string) {
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string
const signed = Utils.hmacSign(bytes, this.authInfo.macKey).toString('base64') // sign the challenge string with our macKey
const data = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID
this.log('resolving login challenge')
return this.query(data)
}
/**
* When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends
* @private
*/
protected generateKeysForAuth(ref: string) {
this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32))
this.onReadyForPhoneAuthentication([
ref,
Buffer.from(this.curveKeys.public).toString('base64'),
this.authInfo.clientID,
])
return this.waitForMessage('s1', [])
}
}

View File

@@ -0,0 +1,2 @@
import WAConnection from './Connect'
export default WAConnection