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:
Renato Alcara
2026-03-01 11:33:25 -03:00
parent 919bc52c77
commit 3535a16b32
3 changed files with 163 additions and 122 deletions
+155 -122
View File
@@ -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)
}) })
+2
View File
@@ -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
+6
View File
@@ -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