diff --git a/Example/example.ts b/Example/example.ts index 10bd833..ec641c2 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -10,12 +10,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 +40,7 @@ const startSock = async() => { const sock = makeWASocket({ version, logger, - printQRInTerminal: true, + printQRInTerminal: !usePairingCode, mobile: useMobile, auth: { creds: state.creds, @@ -53,11 +58,18 @@ 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') + await sock.requestPairingCode(phoneNumber) + } + // 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) { diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index a2703ba..6e228e4 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -1,11 +1,29 @@ +import { Boom } from '@hapi/boom' +import { randomBytes } from 'crypto' import NodeCache from 'node-cache' +import { getBinaryNodeChildBuffer } from '../../lib' 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 { + aesEncryptGCM, + Curve, + decodeMediaRetryNode, + decryptMessageNode, + delay, derivePairingKey, + 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 { extractGroupMetadata } from './groups' import { makeMessagesSocket } from './messages-send' @@ -235,7 +253,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 +324,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 +352,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 +382,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 = await decipherLinkPublicKey(primaryEphemeralPublicKeyWrapped) + const companionSharedKey = Curve.sharedKey(codePairingPublicKey, authState.creds.advKeyPair.private) + 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), Buffer.from(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(primaryIdentityPublicKey, authState.creds.signedIdentityKey.private) + const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random]) + authState.creds.advKeyPair.public = hkdf(identityPayload, 32, { info: 'adv_secret' }) + authState.creds.advKeyPair.private = Buffer.alloc(0) + await sendNode({ + 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 } if(Object.keys(result).length) { @@ -371,6 +446,28 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } } + async function decipherLinkPublicKey(data: Uint8Array | Buffer) { + const buffer = toRequiredBuffer(data) + const salt = buffer.slice(0, 32) + const secretKey = await derivePairingKey(authState.creds.pairingCode!, salt) + const iv = buffer.slice(32, 48) + const payload = buffer.slice(48, 80) + const result = await crypto.subtle.decrypt({ + name: 'AES-CTR', + length: 64, + counter: iv + }, secretKey, payload) + return Buffer.from(result) + } + + 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..4b7e4c7 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -1,13 +1,48 @@ -/* 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 { getBinaryNodeChildBuffer } from '../../lib' 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, aesEncryptGCM, + bindWaitForConnectionUpdate, + bytesToCrockford, + configureSuccessfulPairing, + Curve, derivePairingKey, + generateLoginNode, + generateMdTagPrefix, + generateMobileNode, + generateRegistrationNode, + getCodeFromWSError, + getErrorCodeFromStreamError, + getNextPreKeysNode, hkdf, + makeEventBuffer, + makeNoiseHandler, + printQRIfNecessaryListener, + promiseTimeout +} from '../Utils' +import { + assertNodeErrorFree, + BinaryNode, + binaryNodeToString, + encodeBinaryNode, + getBinaryNodeChild, + getBinaryNodeChildren, + jidEncode, + S_WHATSAPP_NET +} from '../WABinary' import { MobileSocketClient, WebSocketClient } from './Client' /** @@ -141,15 +176,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 +195,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 +246,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 +481,72 @@ export const makeSocket = (config: SocketConfig) => { end(new Boom(msg || 'Intentional Logout', { statusCode: DisconnectReason.loggedOut })) } + const requestPairingCode = async(phoneNumber: string) => { + authState.creds.me = { + id: jidEncode(phoneNumber, 's.whatsapp.net'), + name: '~' + } + 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(randomBytes(32)) + }, + { + 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' + } + ] + } + ] + }) + } + + async function generatePairingKey(salt: Buffer) { + authState.creds.pairingCode = bytesToCrockford(randomBytes(5)) + const key = await derivePairingKey(authState.creds.pairingCode, salt) + const randomIv = randomBytes(16) + const ciphered = await crypto.subtle.encrypt({ + name: 'AES-CTR', + length: 64, + counter: randomIv + }, key, authState.creds.advKeyPair.public) + return Buffer.concat([salt, randomIv, Buffer.from(ciphered)]) + } + ws.on('message', onMessageRecieved) ws.on('open', async() => { try { @@ -477,7 +576,7 @@ export const makeSocket = (config: SocketConfig) => { const refNodes = getBinaryNodeChildren(pairDeviceNode, 'ref') const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64') const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString('base64') - const advB64 = creds.advSecretKey + const advB64 = Buffer.from(creds.advKeyPair.public).toString('base64') let qrMs = qrTimeout || 60_000 // time to let a QR live const genPairQR = () => { @@ -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..38e7c25 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -43,7 +43,7 @@ export type AccountSettings = { export type AuthenticationCreds = SignalCreds & { readonly noiseKey: KeyPair - readonly advSecretKey: string + advKeyPair: KeyPair me?: Contact account?: proto.IADVSignedDeviceIdentity @@ -66,6 +66,8 @@ 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..69c5b82 100644 --- a/src/Utils/auth-utils.ts +++ b/src/Utils/auth-utils.ts @@ -199,7 +199,7 @@ export const initAuthCreds = (): AuthenticationCreds => { signedIdentityKey: identityKey, signedPreKey: signedKeyPair(identityKey, 1), registrationId: generateRegistrationId(), - advSecretKey: randomBytes(32).toString('base64'), + advKeyPair: Curve.generateKeyPair(), processedHistoryMessages: [], nextPreKeyId: 1, firstUnuploadedPreKeyId: 1, @@ -213,6 +213,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/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..2fda427 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,54 @@ 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('') +} + +export async function derivePairingKey(pairingCode: string, salt: Buffer) { + const encoded = new TextEncoder().encode(pairingCode) + const cryptoKey = await crypto.subtle.importKey( + 'raw', + encoded, + { + name: 'PBKDF2' + }, + !1, + [ + 'deriveKey' + ] + ) + return await crypto.subtle.deriveKey({ + name: 'PBKDF2', + hash: 'SHA-256', + salt: salt, + iterations: 2 << 16 + }, cryptoKey, { + name: 'AES-CTR', + length: 256 + }, false, [ + 'encrypt', + 'decrypt' + ]) +} \ No newline at end of file diff --git a/src/Utils/validate-connection.ts b/src/Utils/validate-connection.ts index 6b9e92b..ffd2e5a 100644 --- a/src/Utils/validate-connection.ts +++ b/src/Utils/validate-connection.ts @@ -117,7 +117,7 @@ export const generateRegistrationNode = ( export const configureSuccessfulPairing = ( stanza: BinaryNode, - { advSecretKey, signedIdentityKey, signalIdentities }: Pick + { advKeyPair, signedIdentityKey, signalIdentities }: Pick ) => { const msgId = stanza.attrs.id @@ -137,7 +137,7 @@ export const configureSuccessfulPairing = ( const { details, hmac } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer) // check HMAC matches - const advSign = hmacSign(details, Buffer.from(advSecretKey, 'base64')) + const advSign = hmacSign(details, advKeyPair.public) if(Buffer.compare(hmac, advSign) !== 0) { throw new Boom('Invalid account signature') }