Unread fix + regen QR code changes + Browser message decoding

This commit is contained in:
Adhiraj
2020-07-10 12:27:18 +05:30
parent 08919e51bb
commit 2dad372e75
12 changed files with 150 additions and 3239 deletions

5
.gitignore vendored
View File

@@ -7,4 +7,7 @@ package-lock.json
.env
lib
auth_info_browser.json
yarn.lock
yarn.lock
browser-messages.json
package-lock.json
package-lock.json

3158
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,8 @@
"test": "mocha --timeout 30000 -r ts-node/register src/*/Tests.ts",
"lint": "eslint '*/*.ts' --quiet --fix",
"build": "tsc",
"example": "npx ts-node Example/example.ts"
"example": "npx ts-node Example/example.ts",
"browser-decode": "npx ts-node src/WAConnection/BrowserMessageDecoding.ts"
},
"author": "Adhiraj Singh",
"license": "MIT",

View File

@@ -20,16 +20,16 @@ import { proto } from '../../WAMessage/WAMessage'
export default class WhatsAppWebMessages extends WhatsAppWebBase {
/**
* Send a read receipt to the given ID for a certain message
* @param {string} jid the ID of the person/group whose message you want to mark read
* @param {string} [messageID] optionally, the message ID
* @param jid the ID of the person/group whose message you want to mark read
* @param messageID optionally, the message ID
* @param type whether to read or unread the message
*/
async sendReadReceipt(jid: string, messageID?: string, type: 'read' | 'unread' = 'read') {
const attributes = {
jid: jid,
count: messageID ? '1' : null,
count: type === 'read' ? '1' : '-2',
index: messageID,
owner: 'false',
type: type==='unread' ? 'false' : null
owner: messageID ? 'false' : null
}
return this.setQuery ([['read', attributes, null]])
}

View File

@@ -4,13 +4,11 @@ import * as fs from 'fs'
import * as assert from 'assert'
import { decodeMediaMessage, validateJIDForSending } from './Utils'
import { promiseTimeout } from '../WAConnection/Utils'
import { promiseTimeout, createTimeout } from '../WAConnection/Utils'
require ('dotenv').config () // dotenv to load test jid
const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST_JID=xyz@s.whatsapp.net in a .env file in the root directory
const createTimeout = (timeout) => new Promise(resolve => setTimeout(resolve, timeout))
async function sendAndRetreiveMessage(client: WAClient, content, type: MessageType, options: MessageOptions = {}) {
const response = await client.sendMessage(testJid, content, type, options)
assert.strictEqual(response.status, 200)

View File

@@ -12,6 +12,7 @@ import {
WATag,
MessageLogLevel,
AuthenticationCredentialsBrowser,
Browsers,
} from './Constants'
/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */
@@ -22,9 +23,9 @@ const generateQRCode = function ([ref, publicKey, clientID]) {
export default class WAConnectionBase {
/** The version of WhatsApp Web we're telling the servers we are */
version: [number, number, number] = [2, 2025, 6]
version: [number, number, number] = [2, 2027, 10]
/** The Browser we're telling the WhatsApp Web servers we are */
browserDescription: [string, string] = ['Baileys', 'Baileys']
browserDescription: [string, string, string] = Browsers.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 */

View File

@@ -0,0 +1,39 @@
import fs from 'fs'
import { decryptWA } from './Utils'
import Decoder from '../Binary/Decoder'
interface BrowserMessagesInfo {
encKey: string,
macKey: string,
messages: string[]
}
const file = fs.readFileSync ('./browser-messages.json', {encoding: 'utf-8'})
const json: BrowserMessagesInfo = JSON.parse (file)
const encKey = Buffer.from (json.encKey, 'base64')
const macKey = Buffer.from (json.macKey, 'base64')
const decrypt = buffer => {
try {
return decryptWA (buffer, macKey, encKey, new Decoder())
} catch {
return decryptWA (buffer, macKey, encKey, new Decoder(), true)
}
}
json.messages.forEach ((str, i) => {
const buffer = Buffer.from (str, 'hex')
try {
const [tag, json, binaryTags] = decrypt (buffer)
console.log (
`
${i}.
messageTag: ${tag}
output: ${JSON.stringify(json)}
binaryTags: ${binaryTags}
`
)
} catch (error) {
console.error (`received error in decoding ${i}: ${error}`)
}
})

View File

@@ -2,6 +2,7 @@ import WS from 'ws'
import * as Utils from './Utils'
import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact, MessageLogLevel } from './Constants'
import WAConnectionValidator from './Validation'
import Decoder from '../Binary/Decoder'
export default class WAConnectionConnector extends WAConnectionValidator {
/**
@@ -140,48 +141,12 @@ export default class WAConnectionConnector extends WAConnectionValidator {
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
const decrypted = Utils.decryptWA (message, this.authInfo.macKey, this.authInfo.encKey, new Decoder())
if (!decrypted) {
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]
}
const [messageTag, json] = decrypted
if (this.logLevel === MessageLogLevel.all) {
this.log(messageTag + ', ' + JSON.stringify(json))
}

View File

@@ -1,6 +1,11 @@
import { WA } from '../Binary/Constants'
import { proto } from '../../WAMessage/WAMessage'
export const Browsers: Record<string, (string) => [string, string, string]> = {
ubuntu: browser => ['Ubuntu', browser, '18.04'],
macOS: browser => ['Mac OS', browser, '10.15.3'],
baileys: browser => ['Baileys', browser, '2.0']
}
export enum MessageLogLevel {
none=0,
unhandled=1,

View File

@@ -1,6 +1,8 @@
import * as assert from 'assert'
import * as QR from 'qrcode-terminal'
import WAConnection from './WAConnection'
import { AuthenticationCredentialsBase64 } from './Constants'
import { createTimeout } from './Utils'
describe('QR generation', () => {
it('should generate QR', async () => {
@@ -29,6 +31,23 @@ describe('Test Connect', () => {
conn.close()
auth = conn.base64EncodedAuthInfo()
})
it('should re-generate QR & connect', async () => {
const conn = new WAConnection()
conn.onReadyForPhoneAuthentication = async ([ref, publicKey, clientID]) => {
for (let i = 0; i < 2; i++) {
console.log ('called QR ' + i + ' times')
await createTimeout (3000)
ref = await conn.generateNewQRCode ()
}
const str = ref + ',' + publicKey + ',' + clientID
QR.generate(str, { small: true })
}
const user = await conn.connectSlim(null)
assert.ok(user)
assert.ok(user.id)
conn.close()
})
it('should reconnect', async () => {
const conn = new WAConnection()
const [user, chats, contacts, unread] = await conn.connect(auth, 20*1000)

View File

@@ -1,8 +1,9 @@
import * as Crypto from 'crypto'
import HKDF from 'futoin-hkdf'
import Decoder from '../Binary/Decoder'
import { off } from 'process'
/** 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))
}
@@ -37,6 +38,7 @@ export function hkdf(buffer: Buffer, expandedLength: number, info = null) {
export function randomBytes(length) {
return Crypto.randomBytes(length)
}
export const createTimeout = (timeout) => new Promise(resolve => setTimeout(resolve, timeout))
export function promiseTimeout<T>(ms: number, promise: Promise<T>) {
if (!ms) {
return promise
@@ -66,10 +68,59 @@ export function generateMessageID() {
}
export function errorOnNon200Status(p: Promise<any>) {
return p.then((json) => {
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
})
}
export function decryptWA (message: any, macKey: Buffer, encKey: Buffer, decoder: Decoder, fromMe: boolean=false): [string, Object, [number, number]?] {
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: string = 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
let tags = null
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 (!macKey || !encKey) {
// 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', data]
}
/*
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)) {
throw [7, "checksums don't match"]
}
// 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 = decoder.read(decrypted) // decode the binary message into a JSON array
}
return [messageTag, json, tags]
}

View File

@@ -74,10 +74,14 @@ export default class WAConnectionValidator extends WAConnectionBase {
return this.userMetaData
})
}
/** Refresh QR Code */
protected refreshQRCode() {
/**
* Refresh QR Code
* @returns the new ref
*/
async generateNewQRCode() {
const data = ['admin', 'Conn', 'reref']
return this.query(data)
const response = await this.query(data)
return response.ref as string
}
/**
* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in
@@ -154,31 +158,14 @@ export default class WAConnectionValidator extends WAConnectionBase {
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
*/
/** When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends */
protected async generateKeysForAuth(ref: string) {
this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32))
let retries = 0
let _ref = ref
while (retries < 5) {
retries++
this.onReadyForPhoneAuthentication([
_ref,
Buffer.from(this.curveKeys.public).toString('base64'),
this.authInfo.clientID,
])
try {
return await this.waitForMessage('s1', [], 20 * 1000)
} catch (err) {
const json = await this.refreshQRCode()
_ref = json.ref
}
}
this.onReadyForPhoneAuthentication([
ref,
Buffer.from(this.curveKeys.public).toString('base64'),
this.authInfo.clientID,
])
return this.waitForMessage('s1', [])
}
}