fix: address audit findings in call signaling implementation
- Fix handleCall to process ALL children of <call> node (not just first) - Add CB:relay handler for top-level relay stanzas (TURN servers, tokens) - Add 'video' and 'relay' to WACallUpdateType and getCallStatusFromNode - Add <device-identity /> to voice call offers (verified via Frida capture) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+155
-122
@@ -20,6 +20,7 @@ import type {
|
|||||||
PlaceholderMessageData,
|
PlaceholderMessageData,
|
||||||
SocketConfig,
|
SocketConfig,
|
||||||
WACallEvent,
|
WACallEvent,
|
||||||
|
WACallUpdateType,
|
||||||
WAMessage,
|
WAMessage,
|
||||||
WAMessageKey,
|
WAMessageKey,
|
||||||
WAPatchName
|
WAPatchName
|
||||||
@@ -578,6 +579,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
|
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Voice calls include device-identity (verified via Frida capture)
|
||||||
|
if (!isVideo) {
|
||||||
|
offerContent.push(
|
||||||
|
{ tag: 'device-identity', attrs: {}, content: undefined },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const stanza: BinaryNode = {
|
const stanza: BinaryNode = {
|
||||||
tag: 'call',
|
tag: 'call',
|
||||||
attrs: {
|
attrs: {
|
||||||
@@ -2464,145 +2472,150 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
const handleCall = async (node: BinaryNode) => {
|
const handleCall = async (node: BinaryNode) => {
|
||||||
const { attrs } = node
|
const { attrs } = node
|
||||||
const [infoChild] = getAllBinaryNodeChildren(node)
|
const children = getAllBinaryNodeChildren(node)
|
||||||
const status = getCallStatusFromNode(infoChild!)
|
|
||||||
|
|
||||||
if (!infoChild) {
|
if (!children.length) {
|
||||||
throw new Boom('Missing call info in call node')
|
throw new Boom('Missing call info in call node')
|
||||||
}
|
}
|
||||||
|
|
||||||
const callId = infoChild.attrs['call-id']!
|
// Process ALL children — a <call> node can carry multiple
|
||||||
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
|
// sibling stanzas (e.g. <transport> + <mute_v2>)
|
||||||
|
for (const infoChild of children) {
|
||||||
|
const status = getCallStatusFromNode(infoChild)
|
||||||
|
|
||||||
const call: WACallEvent = {
|
const callId = infoChild.attrs['call-id']!
|
||||||
chatId: attrs.from!,
|
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
|
||||||
from,
|
|
||||||
id: callId,
|
|
||||||
date: new Date(+attrs.t! * 1000),
|
|
||||||
offline: !!attrs.offline,
|
|
||||||
status
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'offer') {
|
const call: WACallEvent = {
|
||||||
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
|
chatId: attrs.from!,
|
||||||
call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid']
|
from,
|
||||||
call.groupJid = infoChild.attrs['group-jid']
|
id: callId,
|
||||||
// Extract and sanitize caller phone number
|
date: new Date(+attrs.t! * 1000),
|
||||||
call.callerPn = sanitizeCallerPn(infoChild.attrs['caller_pn'])
|
offline: !!attrs.offline,
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
// Extract call link info from group_info child
|
if (status === 'offer') {
|
||||||
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
|
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
|
||||||
if (groupInfo) {
|
call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid']
|
||||||
call.isGroup = true
|
call.groupJid = infoChild.attrs['group-jid']
|
||||||
call.linkToken = groupInfo.attrs['link-token']
|
// Extract and sanitize caller phone number
|
||||||
call.media = groupInfo.attrs.media
|
call.callerPn = sanitizeCallerPn(infoChild.attrs['caller_pn'])
|
||||||
call.connectedLimit = groupInfo.attrs['connected-limit']
|
|
||||||
? Number(groupInfo.attrs['connected-limit'])
|
// Extract call link info from group_info child
|
||||||
: undefined
|
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
|
||||||
// Extract participants from group_info
|
if (groupInfo) {
|
||||||
const userNodes = getBinaryNodeChildren(groupInfo, 'user')
|
call.isGroup = true
|
||||||
if (userNodes.length) {
|
call.linkToken = groupInfo.attrs['link-token']
|
||||||
call.participants = userNodes.map(u => ({
|
call.media = groupInfo.attrs.media
|
||||||
jid: u.attrs.jid,
|
call.connectedLimit = groupInfo.attrs['connected-limit']
|
||||||
state: u.attrs.state,
|
? Number(groupInfo.attrs['connected-limit'])
|
||||||
userPn: u.attrs.user_pn,
|
: undefined
|
||||||
type: u.attrs.type,
|
// Extract participants from group_info
|
||||||
}))
|
const userNodes = getBinaryNodeChildren(groupInfo, 'user')
|
||||||
|
if (userNodes.length) {
|
||||||
|
call.participants = userNodes.map(u => ({
|
||||||
|
jid: u.attrs.jid,
|
||||||
|
state: u.attrs.state,
|
||||||
|
userPn: u.attrs.user_pn,
|
||||||
|
type: u.attrs.type,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract link_info (who created the link)
|
||||||
|
const linkInfo = getBinaryNodeChild(infoChild, 'link_info')
|
||||||
|
if (linkInfo) {
|
||||||
|
call.linkCreator = linkInfo.attrs.link_creator
|
||||||
|
call.linkCreatorPn = linkInfo.attrs.link_creator_pn
|
||||||
|
}
|
||||||
|
|
||||||
|
await callOfferCache.set(call.id, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract call link data from group_update
|
||||||
|
if (status === 'group_update') {
|
||||||
|
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
|
||||||
|
if (groupInfo) {
|
||||||
|
call.isGroup = true
|
||||||
|
call.linkToken = groupInfo.attrs['link-token']
|
||||||
|
call.media = groupInfo.attrs.media
|
||||||
|
call.connectedLimit = groupInfo.attrs['connected-limit']
|
||||||
|
? Number(groupInfo.attrs['connected-limit'])
|
||||||
|
: undefined
|
||||||
|
const userNodes = getBinaryNodeChildren(groupInfo, 'user')
|
||||||
|
if (userNodes.length) {
|
||||||
|
call.participants = userNodes.map(u => ({
|
||||||
|
jid: u.attrs.jid,
|
||||||
|
state: u.attrs.state,
|
||||||
|
userPn: u.attrs.user_pn,
|
||||||
|
type: u.attrs.type,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract link_info (who created the link)
|
// Extract reminder data (e.g. link_creator_call_started)
|
||||||
const linkInfo = getBinaryNodeChild(infoChild, 'link_info')
|
if (status === 'reminder') {
|
||||||
if (linkInfo) {
|
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
|
||||||
call.linkCreator = linkInfo.attrs.link_creator
|
if (groupInfo) {
|
||||||
call.linkCreatorPn = linkInfo.attrs.link_creator_pn
|
call.isGroup = true
|
||||||
}
|
call.linkToken = groupInfo.attrs['link-token']
|
||||||
|
call.media = groupInfo.attrs.media
|
||||||
await callOfferCache.set(call.id, call)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract call link data from group_update
|
|
||||||
if (status === 'group_update') {
|
|
||||||
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
|
|
||||||
if (groupInfo) {
|
|
||||||
call.isGroup = true
|
|
||||||
call.linkToken = groupInfo.attrs['link-token']
|
|
||||||
call.media = groupInfo.attrs.media
|
|
||||||
call.connectedLimit = groupInfo.attrs['connected-limit']
|
|
||||||
? Number(groupInfo.attrs['connected-limit'])
|
|
||||||
: undefined
|
|
||||||
const userNodes = getBinaryNodeChildren(groupInfo, 'user')
|
|
||||||
if (userNodes.length) {
|
|
||||||
call.participants = userNodes.map(u => ({
|
|
||||||
jid: u.attrs.jid,
|
|
||||||
state: u.attrs.state,
|
|
||||||
userPn: u.attrs.user_pn,
|
|
||||||
type: u.attrs.type,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Extract reminder data (e.g. link_creator_call_started)
|
// Extract terminate data (reason, duration, call_summary)
|
||||||
if (status === 'reminder') {
|
if (status === 'terminate') {
|
||||||
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
|
call.terminateReason = infoChild.attrs.reason
|
||||||
if (groupInfo) {
|
const callSummary = getBinaryNodeChild(infoChild, 'call_summary')
|
||||||
call.isGroup = true
|
if (callSummary) {
|
||||||
call.linkToken = groupInfo.attrs['link-token']
|
call.media = callSummary.attrs.media
|
||||||
call.media = groupInfo.attrs.media
|
call.duration = callSummary.attrs.call_duration
|
||||||
}
|
? Number(callSummary.attrs.call_duration)
|
||||||
}
|
: undefined
|
||||||
|
const userNodes = getBinaryNodeChildren(callSummary, 'user')
|
||||||
// Extract terminate data (reason, duration, call_summary)
|
if (userNodes.length) {
|
||||||
if (status === 'terminate') {
|
call.participants = userNodes.map(u => ({
|
||||||
call.terminateReason = infoChild.attrs.reason
|
jid: u.attrs.jid,
|
||||||
const callSummary = getBinaryNodeChild(infoChild, 'call_summary')
|
state: u.attrs.state,
|
||||||
if (callSummary) {
|
userPn: u.attrs.user_pn,
|
||||||
call.media = callSummary.attrs.media
|
type: u.attrs.type,
|
||||||
call.duration = callSummary.attrs.call_duration
|
}))
|
||||||
? Number(callSummary.attrs.call_duration)
|
}
|
||||||
: undefined
|
|
||||||
const userNodes = getBinaryNodeChildren(callSummary, 'user')
|
|
||||||
if (userNodes.length) {
|
|
||||||
call.participants = userNodes.map(u => ({
|
|
||||||
jid: u.attrs.jid,
|
|
||||||
state: u.attrs.state,
|
|
||||||
userPn: u.attrs.user_pn,
|
|
||||||
type: u.attrs.type,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract video info from accept/preaccept
|
||||||
|
if (status === 'accept' || status === 'preaccept') {
|
||||||
|
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCall = await callOfferCache.get<WACallEvent>(call.id)
|
||||||
|
|
||||||
|
// use existing call info to populate this event
|
||||||
|
if (existingCall) {
|
||||||
|
call.isVideo = call.isVideo ?? existingCall.isVideo
|
||||||
|
call.isGroup = call.isGroup ?? existingCall.isGroup
|
||||||
|
call.groupJid = call.groupJid ?? existingCall.groupJid
|
||||||
|
// Preserve callerPn across call state updates
|
||||||
|
call.callerPn = call.callerPn || existingCall.callerPn
|
||||||
|
// Preserve call link data across updates
|
||||||
|
call.linkToken = call.linkToken || existingCall.linkToken
|
||||||
|
call.linkCreator = call.linkCreator || existingCall.linkCreator
|
||||||
|
call.linkCreatorPn = call.linkCreatorPn || existingCall.linkCreatorPn
|
||||||
|
call.media = call.media || existingCall.media
|
||||||
|
call.connectedLimit = call.connectedLimit ?? existingCall.connectedLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete data once call has ended
|
||||||
|
if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
|
||||||
|
await callOfferCache.del(call.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.emit('call', [call])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract video info from accept/preaccept
|
|
||||||
if (status === 'accept' || status === 'preaccept') {
|
|
||||||
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingCall = await callOfferCache.get<WACallEvent>(call.id)
|
|
||||||
|
|
||||||
// use existing call info to populate this event
|
|
||||||
if (existingCall) {
|
|
||||||
call.isVideo = call.isVideo ?? existingCall.isVideo
|
|
||||||
call.isGroup = call.isGroup ?? existingCall.isGroup
|
|
||||||
call.groupJid = call.groupJid ?? existingCall.groupJid
|
|
||||||
// Preserve callerPn across call state updates
|
|
||||||
call.callerPn = call.callerPn || existingCall.callerPn
|
|
||||||
// Preserve call link data across updates
|
|
||||||
call.linkToken = call.linkToken || existingCall.linkToken
|
|
||||||
call.linkCreator = call.linkCreator || existingCall.linkCreator
|
|
||||||
call.linkCreatorPn = call.linkCreatorPn || existingCall.linkCreatorPn
|
|
||||||
call.media = call.media || existingCall.media
|
|
||||||
call.connectedLimit = call.connectedLimit ?? existingCall.connectedLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete data once call has ended
|
|
||||||
if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
|
|
||||||
await callOfferCache.del(call.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
ev.emit('call', [call])
|
|
||||||
|
|
||||||
await sendMessageAck(node)
|
await sendMessageAck(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2792,6 +2805,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
await processNode('call', node, 'handling call', handleCall)
|
await processNode('call', node, 'handling call', handleCall)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Top-level <relay> stanzas carry TURN server info, tokens and crypto keys
|
||||||
|
ws.on('CB:relay', async (node: BinaryNode) => {
|
||||||
|
const callId = node.attrs['call-id']
|
||||||
|
const callCreator = node.attrs['call-creator']
|
||||||
|
if (callId) {
|
||||||
|
logger.debug(
|
||||||
|
{ callId, callCreator, uuid: node.attrs.uuid },
|
||||||
|
'received relay info'
|
||||||
|
)
|
||||||
|
ev.emit('call', [{
|
||||||
|
chatId: callCreator || '',
|
||||||
|
from: callCreator || '',
|
||||||
|
id: callId,
|
||||||
|
date: new Date(),
|
||||||
|
offline: false,
|
||||||
|
status: 'relay' as WACallUpdateType,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ws.on('CB:receipt', async node => {
|
ws.on('CB:receipt', async node => {
|
||||||
await processNode('receipt', node, 'handling receipt', handleReceipt)
|
await processNode('receipt', node, 'handling receipt', handleReceipt)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type WACallUpdateType =
|
|||||||
| 'heartbeat'
|
| 'heartbeat'
|
||||||
| 'mute_v2'
|
| 'mute_v2'
|
||||||
| 'enc_rekey'
|
| 'enc_rekey'
|
||||||
|
| 'video'
|
||||||
|
| 'relay'
|
||||||
|
|
||||||
export type WACallParticipant = {
|
export type WACallParticipant = {
|
||||||
jid?: string
|
jid?: string
|
||||||
|
|||||||
@@ -453,6 +453,12 @@ export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => {
|
|||||||
case 'enc_rekey':
|
case 'enc_rekey':
|
||||||
status = 'enc_rekey'
|
status = 'enc_rekey'
|
||||||
break
|
break
|
||||||
|
case 'video':
|
||||||
|
status = 'video'
|
||||||
|
break
|
||||||
|
case 'relay':
|
||||||
|
status = 'relay'
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
status = 'ringing'
|
status = 'ringing'
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user