diff --git a/Example/example.ts b/Example/example.ts index 10bd833..5b59de8 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -1,5 +1,4 @@ import { Boom } from '@hapi/boom' -import parsePhoneNumber from 'libphonenumber-js' import NodeCache from 'node-cache' import readline from 'readline' import makeWASocket, { AnyMessageContent, delay, DisconnectReason, fetchLatestBaileysVersion, getAggregateVotesInPollMessage, makeCacheableSignalKeyStore, makeInMemoryStore, PHONENUMBER_MCC, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src' @@ -10,12 +9,17 @@ logger.level = 'trace' const useStore = !process.argv.includes('--no-store') const doReplies = !process.argv.includes('--no-reply') +const usePairingCode = process.argv.includes('--use-pairing-code') const useMobile = process.argv.includes('--mobile') // external map to store retry counts of messages when decryption/encryption fails // keep this out of the socket itself, so as to prevent a message decryption/encryption loop across socket restarts const msgRetryCounterCache = new NodeCache() +// Read line interface +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) +const question = (text: string) => new Promise((resolve) => rl.question(text, resolve)) + // the store maintains the data of the WA connection in memory // can be written out to a file & read from it const store = useStore ? makeInMemoryStore({ logger }) : undefined @@ -35,7 +39,7 @@ const startSock = async() => { const sock = makeWASocket({ version, logger, - printQRInTerminal: true, + printQRInTerminal: !usePairingCode, mobile: useMobile, auth: { creds: state.creds, @@ -53,18 +57,27 @@ const startSock = async() => { store?.bind(sock.ev) + // Pairing code for Web clients + if(usePairingCode && !sock.authState.creds.registered) { + if(useMobile) { + throw new Error('Cannot use pairing code with mobile api') + } + + const phoneNumber = await question('Please enter your mobile phone number:\n') + const code = await sock.requestPairingCode(phoneNumber) + console.log(`Pairing code: ${code}`) + } + // If mobile was chosen, ask for the code if(useMobile && !sock.authState.creds.registered) { - const question = (text: string) => new Promise((resolve) => rl.question(text, resolve)) - - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) const { registration } = sock.authState.creds || { registration: {} } if(!registration.phoneNumber) { registration.phoneNumber = await question('Please enter your mobile phone number:\n') } - const phoneNumber = parsePhoneNumber(registration!.phoneNumber) + const libPhonenumber = await import("libphonenumber-js") + const phoneNumber = libPhonenumber.parsePhoneNumber(registration!.phoneNumber) if(!phoneNumber?.isValid()) { throw new Error('Invalid phone number: ' + registration!.phoneNumber) } diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index a2703ba..1c4d93a 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -1,12 +1,43 @@ +import { Boom } from '@hapi/boom' +import { randomBytes } from 'crypto' import NodeCache from 'node-cache' import { proto } from '../../WAProto' import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT } from '../Defaults' import { MessageReceiptType, MessageRelayOptions, MessageUserReceipt, SocketConfig, WACallEvent, WAMessageKey, WAMessageStatus, WAMessageStubType, WAPatchName } from '../Types' -import { decodeMediaRetryNode, decryptMessageNode, delay, encodeBigEndian, encodeSignedDeviceIdentity, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils' +import { + aesDecryptCTR, + aesEncryptGCM, + Curve, + decodeMediaRetryNode, + decryptMessageNode, + delay, + derivePairingCodeKey, + encodeBigEndian, + encodeSignedDeviceIdentity, + getCallStatusFromNode, + getHistoryMsg, + getNextPreKeys, + getStatusFromReceiptType, hkdf, + unixTimestampSeconds, + xmppPreKey, + xmppSignedPreKey +} from '../Utils' +import { cleanMessage } from '../Utils' import { makeMutex } from '../Utils/make-mutex' -import { cleanMessage } from '../Utils/process-message' -import { areJidsSameUser, BinaryNode, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, isJidUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' +import { + areJidsSameUser, + BinaryNode, + getAllBinaryNodeChildren, + getBinaryNodeChild, + getBinaryNodeChildBuffer, + getBinaryNodeChildren, + isJidGroup, + isJidUser, + jidDecode, + jidNormalizedUser, + S_WHATSAPP_NET +} from '../WABinary' import { extractGroupMetadata } from './groups' import { makeMessagesSocket } from './messages-send' @@ -235,7 +266,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { case 'remove': case 'add': case 'leave': - const stubType = `GROUP_PARTICIPANT_${child.tag!.toUpperCase()}` + const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}` msg.messageStubType = WAMessageStubType[stubType] const participants = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid) @@ -306,7 +337,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { break case 'devices': const devices = getBinaryNodeChildren(child, 'device') - if(areJidsSameUser(child.attrs.jid, authState.creds!.me!.id)) { + if(areJidsSameUser(child.attrs.jid, authState.creds.me!.id)) { const deviceJids = devices.map(d => d.attrs.jid) logger.info({ deviceJids }, 'got my own devices') } @@ -334,7 +365,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { result.messageStubType = WAMessageStubType.GROUP_CHANGE_ICON if(setPicture) { - result.messageStubParameters = [ setPicture.attrs.id ] + result.messageStubParameters = [setPicture.attrs.id] } result.participant = node?.attrs.author @@ -364,6 +395,63 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } break + case 'link_code_companion_reg': + const linkCodeCompanionReg = getBinaryNodeChild(node, 'link_code_companion_reg') + const ref = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_ref')) + const primaryIdentityPublicKey = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'primary_identity_pub')) + const primaryEphemeralPublicKeyWrapped = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_wrapped_primary_ephemeral_pub')) + const codePairingPublicKey = decipherLinkPublicKey(primaryEphemeralPublicKeyWrapped) + const companionSharedKey = Curve.sharedKey(authState.creds.pairingEphemeralKeyPair.private, codePairingPublicKey) + const random = randomBytes(32) + const linkCodeSalt = randomBytes(32) + const linkCodePairingExpanded = hkdf(companionSharedKey, 32, { + salt: linkCodeSalt, + info: 'link_code_pairing_key_bundle_encryption_key' + }) + const encryptPayload = Buffer.concat([Buffer.from(authState.creds.signedIdentityKey.public), primaryIdentityPublicKey, random]) + const encryptIv = randomBytes(12) + const encrypted = aesEncryptGCM(encryptPayload, linkCodePairingExpanded, encryptIv, Buffer.alloc(0)) + const encryptedPayload = Buffer.concat([linkCodeSalt, encryptIv, encrypted]) + const identitySharedKey = Curve.sharedKey(authState.creds.signedIdentityKey.private, primaryIdentityPublicKey) + const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random]) + authState.creds.advSecretKey = hkdf(identityPayload, 32, { info: 'adv_secret' }).toString('base64') + await query({ + tag: 'iq', + attrs: { + to: S_WHATSAPP_NET, + type: 'set', + id: sock.generateMessageTag(), + xmlns: 'md' + }, + content: [ + { + tag: 'link_code_companion_reg', + attrs: { + jid: authState.creds.me!.id, + stage: 'companion_finish', + }, + content: [ + { + tag: 'link_code_pairing_wrapped_key_bundle', + attrs: {}, + content: encryptedPayload + }, + { + tag: 'companion_identity_public', + attrs: {}, + content: authState.creds.signedIdentityKey.public + }, + { + tag: 'link_code_pairing_ref', + attrs: {}, + content: ref + } + ] + } + ] + }) + authState.creds.registered = true + ev.emit('creds.update', authState.creds) } if(Object.keys(result).length) { @@ -371,6 +459,23 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } } + function decipherLinkPublicKey(data: Uint8Array | Buffer) { + const buffer = toRequiredBuffer(data) + const salt = buffer.slice(0, 32) + const secretKey = derivePairingCodeKey(authState.creds.pairingCode!, salt) + const iv = buffer.slice(32, 48) + const payload = buffer.slice(48, 80) + return aesDecryptCTR(payload, secretKey, iv) + } + + function toRequiredBuffer(data: Uint8Array | Buffer | undefined) { + if(data === undefined) { + throw new Boom('Invalid buffer', { statusCode: 400 }) + } + + return data instanceof Buffer ? data : Buffer.from(data) + } + const willSendMessageAgain = (id: string, participant: string) => { const key = `${id}:${participant}` const retryCount = msgRetryCache.get(key) || 0 diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 259c7cc..7dcd409 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -1,13 +1,49 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Boom } from '@hapi/boom' +import { randomBytes } from 'crypto' import { URL } from 'url' import { promisify } from 'util' import { proto } from '../../WAProto' -import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, MOBILE_ENDPOINT, MOBILE_NOISE_HEADER, MOBILE_PORT, NOISE_WA_HEADER } from '../Defaults' +import { + DEF_CALLBACK_PREFIX, + DEF_TAG_PREFIX, + INITIAL_PREKEY_COUNT, + MIN_PREKEY_COUNT, + MOBILE_ENDPOINT, + MOBILE_NOISE_HEADER, + MOBILE_PORT, + NOISE_WA_HEADER +} from '../Defaults' import { DisconnectReason, SocketConfig } from '../Types' -import { addTransactionCapability, bindWaitForConnectionUpdate, configureSuccessfulPairing, Curve, generateLoginNode, generateMdTagPrefix, generateMobileNode, generateRegistrationNode, getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode, makeNoiseHandler, printQRIfNecessaryListener, promiseTimeout } from '../Utils' -import { makeEventBuffer } from '../Utils/event-buffer' -import { assertNodeErrorFree, BinaryNode, binaryNodeToString, encodeBinaryNode, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary' +import { + addTransactionCapability, + aesEncryptCTR, + bindWaitForConnectionUpdate, + bytesToCrockford, + configureSuccessfulPairing, + Curve, + derivePairingCodeKey, + generateLoginNode, + generateMdTagPrefix, + generateMobileNode, + generateRegistrationNode, + getCodeFromWSError, + getErrorCodeFromStreamError, + getNextPreKeysNode, + makeEventBuffer, + makeNoiseHandler, + printQRIfNecessaryListener, + promiseTimeout +} from '../Utils' +import { + assertNodeErrorFree, + BinaryNode, + binaryNodeToString, + encodeBinaryNode, + getBinaryNodeChild, + getBinaryNodeChildren, + jidEncode, + S_WHATSAPP_NET +} from '../WABinary' import { MobileSocketClient, WebSocketClient } from './Client' /** @@ -34,7 +70,7 @@ export const makeSocket = (config: SocketConfig) => { let url = typeof waWebSocketUrl === 'string' ? new URL(waWebSocketUrl) : waWebSocketUrl - config.mobile = config.mobile || config.auth?.creds?.registered || url.protocol === 'tcp:' + config.mobile = config.mobile || url.protocol === 'tcp:' if(config.mobile && url.protocol !== 'tcp:') { url = new URL(`tcp://${MOBILE_ENDPOINT}:${MOBILE_PORT}`) @@ -141,15 +177,14 @@ export const makeSocket = (config: SocketConfig) => { /** * 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 msgId the message tag to await * @param timeoutMs timeout after which the promise will reject */ const waitForMessage = async(msgId: string, timeoutMs = defaultQueryTimeoutMs) => { let onRecv: (json) => void let onErr: (err) => void try { - const result = await promiseTimeout(timeoutMs, + return await promiseTimeout(timeoutMs, (resolve, reject) => { onRecv = resolve onErr = err => { @@ -161,7 +196,6 @@ export const makeSocket = (config: SocketConfig) => { ws.off('error', onErr) }, ) - return result } finally { ws.off(`TAG:${msgId}`, onRecv!) ws.off('close', onErr!) // if the socket closes, you'll never receive the message @@ -213,7 +247,7 @@ export const makeSocket = (config: SocketConfig) => { node = generateRegistrationNode(creds, config) logger.info({ node }, 'not logged in, attempting registration...') } else { - node = generateLoginNode(creds.me!.id, config) + node = generateLoginNode(creds.me.id, config) logger.info({ node }, 'logging in...') } @@ -448,6 +482,71 @@ export const makeSocket = (config: SocketConfig) => { end(new Boom(msg || 'Intentional Logout', { statusCode: DisconnectReason.loggedOut })) } + const requestPairingCode = async(phoneNumber: string): Promise => { + authState.creds.pairingCode = bytesToCrockford(randomBytes(5)) + authState.creds.me = { + id: jidEncode(phoneNumber, 's.whatsapp.net'), + name: '~' + } + ev.emit('creds.update', authState.creds) + await sendNode({ + tag: 'iq', + attrs: { + to: S_WHATSAPP_NET, + type: 'set', + id: generateMessageTag(), + xmlns: 'md' + }, + content: [ + { + tag: 'link_code_companion_reg', + attrs: { + jid: authState.creds.me.id, + stage: 'companion_hello', + // eslint-disable-next-line camelcase + should_show_push_notification: 'true' + }, + content: [ + { + tag: 'link_code_pairing_wrapped_companion_ephemeral_pub', + attrs: {}, + content: await generatePairingKey() + }, + { + tag: 'companion_server_auth_key_pub', + attrs: {}, + content: authState.creds.noiseKey.public + }, + { + tag: 'companion_platform_id', + attrs: {}, + content: '49' // Chrome + }, + { + tag: 'companion_platform_display', + attrs: {}, + content: config.browser[0] + }, + { + tag: 'link_code_pairing_nonce', + attrs: {}, + content: '0' + } + ] + } + ] + }) + return authState.creds.pairingCode + } + + async function generatePairingKey() { + const salt = randomBytes(32) + const randomIv = randomBytes(16) + const key = derivePairingCodeKey(authState.creds.pairingCode!, salt) + const ciphered = aesEncryptCTR(authState.creds.pairingEphemeralKeyPair.public, key, randomIv) + return Buffer.concat([salt, randomIv, ciphered]) + } + ws.on('message', onMessageRecieved) ws.on('open', async() => { try { @@ -619,6 +718,7 @@ export const makeSocket = (config: SocketConfig) => { onUnexpectedError, uploadPreKeys, uploadPreKeysToServerIfRequired, + requestPairingCode, /** Waits for the connection to WA to reach a state */ waitForConnectionUpdate: bindWaitForConnectionUpdate(ev), } diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts index f49b648..2b853a2 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -43,7 +43,8 @@ export type AccountSettings = { export type AuthenticationCreds = SignalCreds & { readonly noiseKey: KeyPair - readonly advSecretKey: string + readonly pairingEphemeralKeyPair: KeyPair + advSecretKey: string me?: Contact account?: proto.IADVSignedDeviceIdentity @@ -66,6 +67,7 @@ export type AuthenticationCreds = SignalCreds & { registered: boolean backupToken: Buffer registration: RegistrationOptions + pairingCode: string | undefined } export type SignalDataTypeMap = { diff --git a/src/Utils/auth-utils.ts b/src/Utils/auth-utils.ts index 1f79da5..4424e9e 100644 --- a/src/Utils/auth-utils.ts +++ b/src/Utils/auth-utils.ts @@ -196,6 +196,7 @@ export const initAuthCreds = (): AuthenticationCreds => { const identityKey = Curve.generateKeyPair() return { noiseKey: Curve.generateKeyPair(), + pairingEphemeralKeyPair: Curve.generateKeyPair(), signedIdentityKey: identityKey, signedPreKey: signedKeyPair(identityKey, 1), registrationId: generateRegistrationId(), @@ -213,6 +214,7 @@ export const initAuthCreds = (): AuthenticationCreds => { identityId: randomBytes(20), registered: false, backupToken: randomBytes(20), - registration: {} as never + registration: {} as never, + pairingCode: undefined, } } \ No newline at end of file diff --git a/src/Utils/crypto.ts b/src/Utils/crypto.ts index 46c020f..376bf46 100644 --- a/src/Utils/crypto.ts +++ b/src/Utils/crypto.ts @@ -1,4 +1,4 @@ -import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto' +import { createCipheriv, createDecipheriv, createHash, createHmac, pbkdf2Sync, randomBytes } from 'crypto' import HKDF from 'futoin-hkdf' import * as libsignal from 'libsignal' import { KEY_BUNDLE_TYPE } from '../Defaults' @@ -74,6 +74,16 @@ export function aesDecryptGCM(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8 return Buffer.concat([ decipher.update(enc), decipher.final() ]) } +export function aesEncryptCTR(plaintext: Uint8Array, key: Uint8Array, iv: Uint8Array) { + const cipher = createCipheriv('aes-256-ctr', key, iv) + return Buffer.concat([cipher.update(plaintext), cipher.final()]) +} + +export function aesDecryptCTR(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8Array) { + const decipher = createDecipheriv('aes-256-ctr', key, iv) + return Buffer.concat([decipher.update(ciphertext), decipher.final()]) +} + /** 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)) @@ -114,4 +124,8 @@ export function md5(buffer: Buffer) { // HKDF key expansion export function hkdf(buffer: Uint8Array | Buffer, expandedLength: number, info: { salt?: Buffer, info?: string }) { return HKDF(!Buffer.isBuffer(buffer) ? Buffer.from(buffer) : buffer, expandedLength, info) +} + +export function derivePairingCodeKey(pairingCode: string, salt: Buffer) { + return pbkdf2Sync(pairingCode, salt, 2 << 16, 32, 'sha256') } \ No newline at end of file diff --git a/src/Utils/event-buffer.ts b/src/Utils/event-buffer.ts index 63b349d..7408398 100644 --- a/src/Utils/event-buffer.ts +++ b/src/Utils/event-buffer.ts @@ -2,7 +2,7 @@ import EventEmitter from 'events' import { Logger } from 'pino' import { proto } from '../../WAProto' import { BaileysEvent, BaileysEventEmitter, BaileysEventMap, BufferedEventData, Chat, ChatUpdate, Contact, WAMessage, WAMessageStatus } from '../Types' -import { trimUndefineds } from './generics' +import { trimUndefined } from './generics' import { updateMessageWithReaction, updateMessageWithReceipt } from './messages' import { isRealMessage, shouldIncrementChatUnread } from './process-message' @@ -209,7 +209,7 @@ function append( for(const contact of eventData.contacts as Contact[]) { const existingContact = data.historySets.contacts[contact.id] if(existingContact) { - Object.assign(existingContact, trimUndefineds(contact)) + Object.assign(existingContact, trimUndefined(contact)) } else { const historyContactId = `c:${contact.id}` const hasAnyName = contact.notify || contact.name || contact.verifiedName @@ -321,14 +321,14 @@ function append( } if(upsert) { - upsert = Object.assign(upsert, trimUndefineds(contact)) + upsert = Object.assign(upsert, trimUndefined(contact)) } else { upsert = contact data.contactUpserts[contact.id] = upsert } if(data.contactUpdates[contact.id]) { - upsert = Object.assign(data.contactUpdates[contact.id], trimUndefineds(contact)) + upsert = Object.assign(data.contactUpdates[contact.id], trimUndefined(contact)) delete data.contactUpdates[contact.id] } } diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index fda3769..2438682 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -379,7 +379,7 @@ export const isWABusinessPlatform = (platform: string) => { return platform === 'smbi' || platform === 'smba' } -export function trimUndefineds(obj: any) { +export function trimUndefined(obj: any) { for(const key in obj) { if(typeof obj[key] === 'undefined') { delete obj[key] @@ -388,3 +388,27 @@ export function trimUndefineds(obj: any) { return obj } + +const CROCKFORD_CHARACTERS = '123456789ABCDEFGHJKLMNPQRSTVWXYZ' + +export function bytesToCrockford(buffer: Buffer): string { + let value = 0 + let bitCount = 0 + const crockford: string[] = [] + + for(let i = 0; i < buffer.length; i++) { + value = (value << 8) | (buffer[i] & 0xff) + bitCount += 8 + + while(bitCount >= 5) { + crockford.push(CROCKFORD_CHARACTERS.charAt((value >>> (bitCount - 5)) & 31)) + bitCount -= 5 + } + } + + if(bitCount > 0) { + crockford.push(CROCKFORD_CHARACTERS.charAt((value << (5 - bitCount)) & 31)) + } + + return crockford.join('') +} \ No newline at end of file diff --git a/src/Utils/validate-connection.ts b/src/Utils/validate-connection.ts index 6b9e92b..83e1838 100644 --- a/src/Utils/validate-connection.ts +++ b/src/Utils/validate-connection.ts @@ -208,8 +208,7 @@ export const encodeSignedDeviceIdentity = ( account.accountSignatureKey = null } - const accountEnc = proto.ADVSignedDeviceIdentity + return proto.ADVSignedDeviceIdentity .encode(account) .finish() - return accountEnc } \ No newline at end of file