diff --git a/src/auth/types.ts b/src/auth/types.ts index ca79d9fd..21b1c8e2 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -4,7 +4,10 @@ import type { RegistrationInfo, SignedPreKeyRecord } from '@signal/types' import type { WaMobileTransportDeviceInfo } from '@transport/noise/WaMobileClientPayload' import type { WaCommsConfig, WaProxyTransport } from '@transport/types' -export type { WaMobileTransportDeviceInfo } from '@transport/noise/WaMobileClientPayload' +export type { + WaMobilePlatform, + WaMobileTransportDeviceInfo +} from '@transport/noise/WaMobileClientPayload' /** * @sensitive Contains private key material (`noiseKeyPair`, `signedPreKey`, @@ -129,6 +132,11 @@ export interface WaAuthClientOptions { } export interface WaMobileTransportOptions { + /** + * Device/user-agent info encoded into the mobile noise login + * `ClientPayload`. Set `deviceInfo.platform` to `'ios'` for an iPhone + * user-agent; omit or use `'android'` for Android (the historical default). + */ readonly deviceInfo: WaMobileTransportDeviceInfo readonly tcpUrl?: string readonly passive?: boolean diff --git a/src/transport/index.ts b/src/transport/index.ts index defa3915..141abcc7 100644 --- a/src/transport/index.ts +++ b/src/transport/index.ts @@ -44,9 +44,13 @@ export { WaKeepAlive } from '@transport/keepalive/WaKeepAlive' export { WaNodeOrchestrator } from '@transport/node/WaNodeOrchestrator' export { WaNodeTransport } from '@transport/node/WaNodeTransport' export { WaMobileTcpSocket, WaMobileTcpSocketCtor } from '@transport/node/WaMobileTcpSocket' -export { buildMobileLoginPayload } from '@transport/noise/WaMobileClientPayload' +export { + buildMobileLoginPayload, + WA_MOBILE_PLATFORMS +} from '@transport/noise/WaMobileClientPayload' export type { WaMobileLoginPayloadConfig, + WaMobilePlatform, WaMobileTransportDeviceInfo } from '@transport/noise/WaMobileClientPayload' export { assertIqResult, buildIqNode, parseIqError, queryWithContext } from '@transport/node/query' diff --git a/src/transport/noise/WaMobileClientPayload.ts b/src/transport/noise/WaMobileClientPayload.ts index e560e88b..13b10743 100644 --- a/src/transport/noise/WaMobileClientPayload.ts +++ b/src/transport/noise/WaMobileClientPayload.ts @@ -2,7 +2,38 @@ import { randomUUID } from 'node:crypto' import { type Proto, proto } from '@proto' +/** + * Mobile OS advertised in the noise {@link Proto.ClientPayload} user-agent. + * Distinct from companion DeviceProps.PlatformType used in Web pairing. + */ +export const WA_MOBILE_PLATFORMS = Object.freeze({ + ANDROID: 'android', + IOS: 'ios' +} as const) + +export type WaMobilePlatform = (typeof WA_MOBILE_PLATFORMS)[keyof typeof WA_MOBILE_PLATFORMS] + +const MOBILE_PLATFORM_TO_PROTO: Readonly< + Record +> = Object.freeze({ + [WA_MOBILE_PLATFORMS.ANDROID]: proto.ClientPayload.UserAgent.Platform.ANDROID, + [WA_MOBILE_PLATFORMS.IOS]: proto.ClientPayload.UserAgent.Platform.IOS +}) + +const MOBILE_BUSINESS_PLATFORM_TO_PROTO: Readonly< + Record +> = Object.freeze({ + [WA_MOBILE_PLATFORMS.ANDROID]: proto.ClientPayload.UserAgent.Platform.SMB_ANDROID, + [WA_MOBILE_PLATFORMS.IOS]: proto.ClientPayload.UserAgent.Platform.SMB_IOS +}) + export interface WaMobileTransportDeviceInfo { + /** + * OS advertised as `UserAgent.platform`. Defaults to {@link WA_MOBILE_PLATFORMS.ANDROID}. + * Use `'ios'` for an iPhone client (`UserAgent.Platform.IOS` / + * `SMB_IOS` when {@link business} is true). + */ + readonly platform?: WaMobilePlatform readonly manufacturer: string readonly device: string readonly osVersion: string @@ -15,7 +46,19 @@ export interface WaMobileTransportDeviceInfo { readonly phoneId?: string readonly deviceBoard?: string readonly deviceModelType?: string + /** + * When `true`, advertise the SMB platform variant (`SMB_ANDROID` / + * `SMB_IOS`) instead of the consumer one. + */ readonly business?: boolean + /** Defaults to `PHONE`. */ + readonly deviceType?: Proto.ClientPayload.UserAgent.DeviceType + /** + * App distribution. iOS defaults to `APPSTORE` (WhatsApp iOS from the App Store); + * Android omits the field unless set explicitly. + */ + readonly distributionChannel?: Proto.ClientPayload.UserAgent.DistributionChannel + readonly deviceExpId?: string } export interface WaMobileLoginPayloadConfig { @@ -53,10 +96,42 @@ function parseAppVersion(version: string): ParsedAppVersion { } } +function resolveMobilePlatformKey(platform: string | undefined): WaMobilePlatform { + const normalized = (platform ?? WA_MOBILE_PLATFORMS.ANDROID).trim().toLowerCase() + if (!Object.prototype.hasOwnProperty.call(MOBILE_PLATFORM_TO_PROTO, normalized)) { + throw new Error( + `mobile login payload requires platform 'android' or 'ios', got ${JSON.stringify(platform)}` + ) + } + return normalized as WaMobilePlatform +} + +function resolveMobilePlatform( + platformKey: WaMobilePlatform, + business: boolean | undefined +): Proto.ClientPayload.UserAgent.Platform { + return business === true + ? MOBILE_BUSINESS_PLATFORM_TO_PROTO[platformKey] + : MOBILE_PLATFORM_TO_PROTO[platformKey] +} + +function resolveDistributionChannel( + info: WaMobileTransportDeviceInfo, + platformKey: WaMobilePlatform +): Proto.ClientPayload.UserAgent.DistributionChannel | undefined { + if (info.distributionChannel !== undefined) return info.distributionChannel + if (platformKey === WA_MOBILE_PLATFORMS.IOS) { + return proto.ClientPayload.UserAgent.DistributionChannel.APPSTORE + } + return undefined +} + /** * Builds the encoded {@link Proto.ClientPayload} bytes the WhatsApp Mobile - * transport sends after the noise login handshake. Throws when - * `username`/`appVersion` are missing/invalid. + * transport sends after the noise login handshake. Throws when `username` is + * invalid, or when `deviceInfo.platform` is not `'android'` / `'ios'`. + * Malformed `appVersion` components fall back to safe defaults instead of + * throwing. */ export function buildMobileLoginPayload(config: WaMobileLoginPayloadConfig): Uint8Array { if (!Number.isSafeInteger(config.username) || config.username <= 0) { @@ -64,11 +139,12 @@ export function buildMobileLoginPayload(config: WaMobileLoginPayloadConfig): Uin } const info = config.deviceInfo const version = parseAppVersion(info.appVersion) + const platformKey = resolveMobilePlatformKey(info.platform) + const platform = resolveMobilePlatform(platformKey, info.business) + const distributionChannel = resolveDistributionChannel(info, platformKey) const userAgent = { - platform: info.business - ? proto.ClientPayload.UserAgent.Platform.SMB_ANDROID - : proto.ClientPayload.UserAgent.Platform.ANDROID, + platform, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, appVersion: version, mcc: info.mcc ?? '000', @@ -80,9 +156,11 @@ export function buildMobileLoginPayload(config: WaMobileLoginPayloadConfig): Uin phoneId: info.phoneId ?? randomUUID(), localeLanguageIso6391: info.localeLanguageIso6391 ?? 'en', localeCountryIso31661Alpha2: info.localeCountryIso31661Alpha2 ?? 'US', - deviceType: proto.ClientPayload.UserAgent.DeviceType.PHONE, + deviceType: info.deviceType ?? proto.ClientPayload.UserAgent.DeviceType.PHONE, deviceBoard: info.deviceBoard, - deviceModelType: info.deviceModelType + deviceModelType: info.deviceModelType, + deviceExpId: info.deviceExpId, + distributionChannel } as typeof proto.ClientPayload.prototype.userAgent return proto.ClientPayload.encode({ diff --git a/src/transport/noise/__tests__/WaMobileClientPayload.test.ts b/src/transport/noise/__tests__/WaMobileClientPayload.test.ts index 77c56a45..c593b9d9 100644 --- a/src/transport/noise/__tests__/WaMobileClientPayload.test.ts +++ b/src/transport/noise/__tests__/WaMobileClientPayload.test.ts @@ -4,6 +4,7 @@ import test from 'node:test' import { proto } from '@proto' import { buildMobileLoginPayload, + WA_MOBILE_PLATFORMS, type WaMobileTransportDeviceInfo } from '@transport/noise/WaMobileClientPayload' @@ -209,3 +210,121 @@ test('buildMobileLoginPayload omits pushName/yearClass/memClass when absent', () assert.ok(!payload.yearClass) assert.ok(!payload.memClass) }) + +test('buildMobileLoginPayload defaults platform to ANDROID and omits distributionChannel', () => { + const bytes = buildMobileLoginPayload({ + username: 5511987654321, + deviceInfo: BASE_DEVICE + }) + const ua = proto.ClientPayload.decode(bytes).userAgent + assert.ok(ua) + assert.equal(ua.platform, proto.ClientPayload.UserAgent.Platform.ANDROID) + assert.equal(Object.prototype.hasOwnProperty.call(ua, 'distributionChannel'), false) +}) + +test('buildMobileLoginPayload emits an IOS userAgent with App Store distribution', () => { + const bytes = buildMobileLoginPayload({ + username: 5511987654321, + deviceInfo: { + platform: WA_MOBILE_PLATFORMS.IOS, + manufacturer: 'Apple', + device: 'iPhone', + osVersion: '18.4', + osBuildNumber: '22E240', + appVersion: '25.11.74', + mcc: '724', + mnc: '06', + localeLanguageIso6391: 'pt', + localeCountryIso31661Alpha2: 'BR', + phoneId: '11111111-1111-1111-1111-111111111111', + deviceModelType: 'iPhone 16 Pro' + } + }) + const ua = proto.ClientPayload.decode(bytes).userAgent + assert.ok(ua) + assert.equal(ua.platform, proto.ClientPayload.UserAgent.Platform.IOS) + assert.equal(ua.manufacturer, 'Apple') + assert.equal(ua.device, 'iPhone') + assert.equal(ua.osVersion, '18.4') + assert.equal(ua.osBuildNumber, '22E240') + assert.equal(ua.deviceModelType, 'iPhone 16 Pro') + assert.equal(ua.deviceType, proto.ClientPayload.UserAgent.DeviceType.PHONE) + assert.equal(ua.distributionChannel, proto.ClientPayload.UserAgent.DistributionChannel.APPSTORE) + assert.ok(ua.appVersion) + assert.equal(ua.appVersion.primary, 25) + assert.equal(ua.appVersion.secondary, 11) + assert.equal(ua.appVersion.tertiary, 74) +}) + +test('buildMobileLoginPayload emits SMB_IOS when business is set on an iOS device', () => { + const bytes = buildMobileLoginPayload({ + username: 5511987654321, + deviceInfo: { + platform: WA_MOBILE_PLATFORMS.IOS, + business: true, + manufacturer: 'Apple', + device: 'iPhone', + osVersion: '18.4', + osBuildNumber: '22E240', + appVersion: '25.11.74' + } + }) + const ua = proto.ClientPayload.decode(bytes).userAgent + assert.equal(ua?.platform, proto.ClientPayload.UserAgent.Platform.SMB_IOS) +}) + +test('buildMobileLoginPayload honours an explicit android platform', () => { + const bytes = buildMobileLoginPayload({ + username: 5511987654321, + deviceInfo: { ...BASE_DEVICE, platform: WA_MOBILE_PLATFORMS.ANDROID } + }) + const ua = proto.ClientPayload.decode(bytes).userAgent + assert.ok(ua) + assert.equal(ua.platform, proto.ClientPayload.UserAgent.Platform.ANDROID) + assert.equal(Object.prototype.hasOwnProperty.call(ua, 'distributionChannel'), false) +}) + +test('buildMobileLoginPayload forwards an explicit iOS distributionChannel override', () => { + const bytes = buildMobileLoginPayload({ + username: 5511987654321, + deviceInfo: { + platform: 'ios', + manufacturer: 'Apple', + device: 'iPhone', + osVersion: '18.4', + osBuildNumber: '18.4', + appVersion: '25.11.74', + distributionChannel: proto.ClientPayload.UserAgent.DistributionChannel.TESTFLIGHT + } + }) + const ua = proto.ClientPayload.decode(bytes).userAgent + assert.equal( + ua?.distributionChannel, + proto.ClientPayload.UserAgent.DistributionChannel.TESTFLIGHT + ) +}) + +test('buildMobileLoginPayload rejects unknown platforms', () => { + assert.throws( + () => + buildMobileLoginPayload({ + username: 5511987654321, + deviceInfo: { + ...BASE_DEVICE, + platform: 'windows' as WaMobileTransportDeviceInfo['platform'] + } + }), + /platform 'android' or 'ios'/ + ) + assert.throws( + () => + buildMobileLoginPayload({ + username: 5511987654321, + deviceInfo: { + ...BASE_DEVICE, + platform: '__proto__' as WaMobileTransportDeviceInfo['platform'] + } + }), + /platform 'android' or 'ios'/ + ) +})