feat: native-mobile-api

This commit is contained in:
SamuelScheit
2023-04-20 13:01:11 +02:00
parent 28be45a9b4
commit ef673f62ca
17 changed files with 940 additions and 74 deletions

View File

@@ -1,6 +1,6 @@
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import { UserFacingSocketConfig } from '../Types'
import { makeBusinessSocket as _makeSocket } from './business'
import { makeRegistrationSocket as _makeSocket } from './registration'
// export the last socket layer
const makeWASocket = (config: UserFacingSocketConfig) => (

View File

@@ -0,0 +1,47 @@
import { Socket } from 'net'
import { MOBILE_ENDPOINT, MOBILE_PORT } from '../Defaults'
import { SocketConfig } from '../Types'
export class MobileSocket extends Socket {
constructor(public config: SocketConfig) {
super()
if(config.auth.creds.registered) {
this.connect()
}
this.on('data', (d) => {
this.emit('message', d)
})
}
override connect() {
return super.connect(MOBILE_PORT, MOBILE_ENDPOINT, () => {
this.emit('open')
})
}
get isOpen(): boolean {
return this.readyState === 'open'
}
get isClosed(): boolean {
return this.readyState === 'closed'
}
get isClosing(): boolean {
return this.isClosed
}
get isConnecting(): boolean {
return this.readyState === 'opening'
}
close(): void {
this.end()
}
send(data: unknown, cb?: ((err?: Error | undefined) => void) | undefined) {
return super.write(data as Uint8Array | string, cb as ((err?: Error | undefined) => void))
}
}

251
src/Socket/registration.ts Normal file
View File

@@ -0,0 +1,251 @@
/* eslint-disable camelcase */
import { MOBILE_REGISTRATION_ENDPOINT, MOBILE_TOKEN, MOBILE_USERAGENT, REGISTRATION_PUBLIC_KEY } from '../Defaults'
import { KeyPair, SignedKeyPair, SocketConfig } from '../Types'
import { aesEncryptGCM, Curve, md5 } from '../Utils/crypto'
import { jidEncode } from '../WABinary'
import { makeBusinessSocket } from './business'
import { MobileSocket } from './mobile-socket'
function urlencode(str: string) {
return str.replace(/-/g, '%2d').replace(/_/g, '%5f').replace(/~/g, '%7e')
}
const validRegistrationOptions = (config: RegistrationOptions) => config?.phoneNumberCountryCode &&
config.phoneNumberNationalNumber &&
config.phoneNumberMobileCountryCode
export const makeRegistrationSocket = (config: SocketConfig) => {
const sock = makeBusinessSocket(config)
const register = async(code: string) => {
if(!validRegistrationOptions(config.auth.creds.registration)) {
throw new Error('please specify the registration options')
}
const result = await mobileRegister({ ...sock.authState.creds, ...sock.authState.creds.registration as RegistrationOptions, code })
sock.authState.creds.me = {
id: jidEncode(result.login!, 's.whatsapp.net'),
name: '~'
}
sock.authState.creds.registered = true
sock.ev.emit('creds.update', sock.authState.creds)
if(sock.ws instanceof MobileSocket) {
sock.ws.connect()
}
return result
}
const requestRegistrationCode = async(registrationOptions?: RegistrationOptions) => {
registrationOptions = registrationOptions || config.auth.creds.registration
if(!validRegistrationOptions(registrationOptions)) {
throw new Error('Invalid registration options')
}
sock.authState.creds.registration = registrationOptions
sock.ev.emit('creds.update', sock.authState.creds)
return mobileRegisterCode({ ...config.auth.creds, ...registrationOptions })
}
return {
...sock,
register,
requestRegistrationCode,
}
}
// Backup_token: Base64.getEncoder().encodeToString(Arrays.copyOfRange(Base64.getDecoder().decode(UUID.randomUUID().toString().replace('-','')),0,15))
export interface RegistrationData {
registrationId: number
signedPreKey: SignedKeyPair
noiseKey: KeyPair
signedIdentityKey: KeyPair
identityId: Buffer
phoneId: string
deviceId: string
backupToken: Buffer
}
export interface RegistrationOptions {
/** your phone number */
phoneNumber?: string
/** the country code of your phone number */
phoneNumberCountryCode: string
/** your phone number without country code */
phoneNumberNationalNumber: string
/** the country code of your mobile network
* @see {@link https://de.wikipedia.org/wiki/Mobile_Country_Code}
*/
phoneNumberMobileCountryCode: string
/** the network code of your mobile network
* @see {@link https://de.wikipedia.org/wiki/Mobile_Network_Code}
*/
phoneNumberMobileNetworkCode: string
/**
* How to send the one time code
*/
method?: 'sms' | 'voice'
}
export type RegistrationParams = RegistrationData & RegistrationOptions
function convertBufferToUrlHex(buffer: Buffer) {
var id = ''
buffer.forEach((x) => {
// encode random identity_id buffer as percentage url encoding
id += `%${x.toString(16).padStart(2, '0').toLowerCase()}`
})
return id
}
export function registrationParams(params: RegistrationParams) {
const e_regid = Buffer.alloc(4)
e_regid.writeInt32BE(params.registrationId)
const e_skey_id = Buffer.alloc(3)
e_skey_id.writeInt16BE(params.signedPreKey.keyId)
params.phoneNumberCountryCode = params.phoneNumberCountryCode.replace('+', '').trim()
params.phoneNumberNationalNumber = params.phoneNumberNationalNumber.replace(/[/-\s)(]/g, '').trim()
return {
cc: params.phoneNumberCountryCode,
in: params.phoneNumberNationalNumber,
Rc: '0',
lg: 'en',
lc: 'GB',
mistyped: '6',
authkey: Buffer.from(params.noiseKey.public).toString('base64url'),
e_regid: e_regid.toString('base64url'),
e_keytype: 'BQ',
e_ident: Buffer.from(params.signedIdentityKey.public).toString('base64url'),
// e_skey_id: e_skey_id.toString('base64url'),
e_skey_id: 'AAAA',
e_skey_val: Buffer.from(params.signedPreKey.keyPair.public).toString('base64url'),
e_skey_sig: Buffer.from(params.signedPreKey.signature).toString('base64url'),
fdid: params.phoneId,
network_ratio_type: '1',
expid: params.deviceId,
simnum: '1',
hasinrc: '1',
pid: Math.floor(Math.random() * 1000).toString(),
id: convertBufferToUrlHex(params.identityId),
backup_token: convertBufferToUrlHex(params.backupToken),
token: md5(Buffer.concat([MOBILE_TOKEN, Buffer.from(params.phoneNumberNationalNumber)])).toString('hex'),
}
}
/**
* Requests a registration code for the given phone number.
*/
export function mobileRegisterCode(params: RegistrationParams) {
return mobileRegisterFetch('/code', {
params: {
...registrationParams(params),
mcc: `${params.phoneNumberMobileCountryCode}`.padStart(3, '0'),
mnc: `${params.phoneNumberMobileNetworkCode || '001'}`.padStart(3, '0'),
sim_mcc: '000',
sim_mnc: '000',
method: params?.method || 'sms',
reason: '',
hasav: '1'
},
})
}
export function mobileRegisterExists(params: RegistrationParams) {
return mobileRegisterFetch('/exist', {
params: registrationParams(params)
})
}
/**
* Registers the phone number on whatsapp with the received OTP code.
*/
export async function mobileRegister(params: RegistrationParams & { code: string }) {
//const result = await mobileRegisterFetch(`/reg_onboard_abprop?cc=${params.phoneNumberCountryCode}&in=${params.phoneNumberNationalNumber}&rc=0`)
return mobileRegisterFetch('/register', {
params: { ...registrationParams(params), code: params.code.replace('-', '') },
})
}
/**
* Encrypts the given string as AEAD aes-256-gcm with the public whatsapp key and a random keypair.
*/
export function mobileRegisterEncrypt(data: string) {
const keypair = Curve.generateKeyPair()
const key = Curve.sharedKey(keypair.private, REGISTRATION_PUBLIC_KEY)
const buffer = aesEncryptGCM(Buffer.from(data), new Uint8Array(key), Buffer.alloc(12), Buffer.alloc(0))
return Buffer.concat([Buffer.from(keypair.public), buffer]).toString('base64url')
}
export async function mobileRegisterFetch(path: string, opts: { params?: Record<string, string>, headers?: Record<string, string> } = {}) {
let url = `${MOBILE_REGISTRATION_ENDPOINT}${path}`
if(opts.params) {
const parameter = [] as string[]
for(const param in opts.params) {
parameter.push(param + '=' + urlencode(opts.params[param]))
}
console.log('parameter', opts.params, parameter)
// const params = urlencode(mobileRegisterEncrypt(parameter.join('&')))
// url += `?ENC=${params}`
url += `?${parameter.join('&')}`
}
if(!opts.headers) {
opts.headers = {}
}
opts.headers['User-Agent'] = MOBILE_USERAGENT
const response = await fetch(url, opts)
const text = await response.text()
try {
var json = JSON.parse(text)
} catch(error) {
throw text
}
if(!response.ok || json.reason) {
throw json
}
if(json.status && !['ok', 'sent'].includes(json.status)) {
throw json
}
return json as ExistsResponse
}
export interface ExistsResponse {
status: 'fail'
voice_length?: number
voice_wait?: number
sms_length?: number
sms_wait?: number
reason?: 'incorrect' | 'missing_param'
login?: string
flash_type?: number
ab_hash?: string
ab_key?: string
exp_cfg?: string
lid?: string
}

View File

@@ -1,12 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Boom } from '@hapi/boom'
import { promisify } from 'util'
import WebSocket from 'ws'
import { proto } from '../../WAProto'
import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, DEFAULT_ORIGIN, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT } from '../Defaults'
import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, MOBILE_NOISE_HEADER, NOISE_WA_HEADER } from '../Defaults'
import { DisconnectReason, SocketConfig } from '../Types'
import { addTransactionCapability, bindWaitForConnectionUpdate, configureSuccessfulPairing, Curve, generateLoginNode, generateMdTagPrefix, generateRegistrationNode, getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode, makeNoiseHandler, printQRIfNecessaryListener, promiseTimeout } from '../Utils'
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, encodeBinaryNode, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary'
import { assertNodeErrorFree, BinaryNode, binaryNodeToString, encodeBinaryNode, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary'
import { MobileSocket } from './mobile-socket'
import { WebSocket } from './web-socket'
/**
* Connects to WA servers and performs:
@@ -14,37 +16,35 @@ import { assertNodeErrorFree, BinaryNode, encodeBinaryNode, getBinaryNodeChild,
* - listen to messages and emit events
* - query phone connection
*/
export const makeSocket = ({
waWebSocketUrl,
connectTimeoutMs,
logger,
agent,
keepAliveIntervalMs,
version,
browser,
auth: authState,
printQRInTerminal,
defaultQueryTimeoutMs,
syncFullHistory,
transactionOpts,
qrTimeout,
options,
makeSignalRepository
}: SocketConfig) => {
const ws = new WebSocket(waWebSocketUrl, undefined, {
origin: DEFAULT_ORIGIN,
headers: options.headers as {},
handshakeTimeout: connectTimeoutMs,
timeout: connectTimeoutMs,
agent
})
export const makeSocket = (config: SocketConfig) => {
const {
connectTimeoutMs,
logger,
keepAliveIntervalMs,
browser,
auth: authState,
printQRInTerminal,
defaultQueryTimeoutMs,
transactionOpts,
qrTimeout,
makeSignalRepository,
} = config
config.mobile = config.mobile || config.auth.creds.registered
const ws = config.mobile ? new MobileSocket(config) : new WebSocket(config)
ws.setMaxListeners(0)
const ev = makeEventBuffer(logger)
/** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */
const ephemeralKeyPair = Curve.generateKeyPair()
/** WA noise protocol wrapper */
const noise = makeNoiseHandler(ephemeralKeyPair, logger)
const noise = makeNoiseHandler({
keyPair: ephemeralKeyPair,
NOISE_HEADER: config.mobile ? MOBILE_NOISE_HEADER : NOISE_WA_HEADER,
mobile: config.mobile,
logger
})
const { creds } = authState
// add transaction capability
@@ -63,7 +63,7 @@ export const makeSocket = ({
const sendPromise = promisify<void>(ws.send)
/** send a raw buffer */
const sendRawMessage = async(data: Uint8Array | Buffer) => {
if(ws.readyState !== ws.OPEN) {
if(!ws.isOpen) {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
@@ -84,7 +84,7 @@ export const makeSocket = ({
/** send a binary node */
const sendNode = (frame: BinaryNode) => {
if(logger.level === 'trace') {
logger.trace({ msgId: frame.attrs.id, fromMe: true, frame }, 'communication')
logger.trace(binaryNodeToString(frame), 'xml send')
}
const buff = encodeBinaryNode(frame)
@@ -101,7 +101,7 @@ export const makeSocket = ({
/** await the next incoming message */
const awaitNextMessage = async<T>(sendMsg?: Uint8Array) => {
if(ws.readyState !== ws.OPEN) {
if(!ws.isOpen) {
throw new Boom('Connection Closed', {
statusCode: DisconnectReason.connectionClosed
})
@@ -186,21 +186,21 @@ export const makeSocket = ({
}
helloMsg = proto.HandshakeMessage.fromObject(helloMsg)
logger.info({ browser, helloMsg }, 'connected to WA Web')
logger.info({ browser, helloMsg }, 'connected to WA')
const init = proto.HandshakeMessage.encode(helloMsg).finish()
const result = await awaitNextMessage<Uint8Array>(init)
const handshake = proto.HandshakeMessage.decode(result)
logger.trace({ handshake }, 'handshake recv from WA Web')
logger.trace({ handshake }, 'handshake recv from WA')
const keyEnc = noise.processHandshake(handshake, creds.noiseKey)
const config = { version, browser, syncFullHistory }
let node: proto.IClientPayload
if(!creds.me) {
if(config.mobile) {
node = generateMobileNode(config)
} else if(!creds.me) {
node = generateRegistrationNode(creds, config)
logger.info({ node }, 'not logged in, attempting registration...')
} else {
@@ -276,7 +276,7 @@ export const makeSocket = ({
const msgId = frame.attrs.id
if(logger.level === 'trace') {
logger.trace({ msgId, fromMe: false, frame }, 'communication')
logger.trace(binaryNodeToString(frame), 'recv xml')
}
/* Check if this is a response to a message we sent */
@@ -321,7 +321,7 @@ export const makeSocket = ({
ws.removeAllListeners('open')
ws.removeAllListeners('message')
if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
if(!ws.isClosed && !ws.isClosing) {
try {
ws.close()
} catch{ }
@@ -338,11 +338,11 @@ export const makeSocket = ({
}
const waitForSocketOpen = async() => {
if(ws.readyState === ws.OPEN) {
if(ws.isOpen) {
return
}
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
if(ws.isClosed || ws.isClosing) {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
@@ -375,7 +375,7 @@ export const makeSocket = ({
*/
if(diff > keepAliveIntervalMs + 5000) {
end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
} else if(ws.readyState === ws.OPEN) {
} else if(ws.isOpen) {
// if its all good, send a keep alive request
query(
{
@@ -472,7 +472,7 @@ export const makeSocket = ({
let qrMs = qrTimeout || 60_000 // time to let a QR live
const genPairQR = () => {
if(ws.readyState !== ws.OPEN) {
if(!ws.isOpen) {
return
}

31
src/Socket/web-socket.ts Normal file
View File

@@ -0,0 +1,31 @@
import { WebSocket as WS } from 'ws'
import { DEFAULT_ORIGIN } from '../Defaults'
import { SocketConfig } from '../Types'
export class WebSocket extends WS {
constructor(public config: SocketConfig) {
super(config.waWebSocketUrl, undefined, {
origin: DEFAULT_ORIGIN,
headers: config.options.headers as Record<string, string>,
handshakeTimeout: config.connectTimeoutMs,
timeout: config.connectTimeoutMs,
agent: config.agent,
})
}
get isOpen() {
return this.readyState === WS.OPEN
}
get isClosed() {
return this.readyState === WS.CLOSED
}
get isClosing() {
return this.readyState === WS.CLOSING
}
get isConnecting() {
return this.readyState === WS.CONNECTING
}
}