Compare commits

..

3 Commits

Author SHA1 Message Date
Renato Alcara f5c07163f8 fix: address PR review — parallelize LID resolution, use helper consistently
- normalizeGroupMetadata: resolve participant LIDs with Promise.all
  instead of sequential loop (perf on large groups)
- handleCall: resolve participant JIDs with Promise.all
- handleBadAck: use normalizeKeyLidToPn() helper instead of manual
  resolveLidToPn + assignment (consistency with other code paths)
- CB:relay: resolve callCreator LID→PN before emitting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:39:52 -03:00
Renato Alcara 0a43ad849d fix: normalize LID→PN in handleBadAck, media retry, and PDO recovery
Additional leak points found during final audit:
- handleBadAck: key.remoteJid from ack stanza could be LID
- messages.media-update: media retry key JIDs not normalized
- CTWA PDO recovery: webMessageInfo.key from phone response not normalized

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:29:22 -03:00
Renato Alcara ac7bb321d4 feat: centralized LID→PN normalization for all emitted events
WhatsApp's server increasingly uses LID (Linked ID) as the primary
addressing format. Consumers (ZPRO, etc.) expect phone numbers (PN),
not opaque LID identifiers. This adds comprehensive LID→PN resolution
across all ev.emit() paths so downstream consumers always receive PN.

Key changes:

- Add resolveLidToPn() and normalizeKeyLidToPn() centralized helpers
  in process-message.ts for consistent LID→PN resolution

- normalizeMessageJids() fast path: use alt JID directly from stanza
  attributes (zero I/O, eliminates race condition with LIDMappingStore)

- Await storeLIDPNMappings() before normalization in message receipt
  flow (was fire-and-forget, could race with subsequent getPNForLID)

- Normalize LID→PN in all event emission points:
  * messages.upsert (keys, nested reaction/poll keys, participantAlt)
  * presence.update (jid + participant)
  * message-receipt.update (key + userJid)
  * contacts.update (picture notifications)
  * blocklist.update (blocklist JIDs)
  * call events (chatId, from)
  * group metadata (participants, owner, subjectOwner)
  * group notifications (acting participant, add/remove/promote/demote)
  * newsletter notifications (author, user JIDs)
  * sync actions (mutation index normalization)

- Make handlePresenceUpdate and handleGroupNotification async to
  support await on LID resolution

- Add normalizeGroupMetadata() helper in groups.ts for all
  extractGroupMetadata call sites

Performance: ~0.01ms per message (LRU cache hit). No impact on
message sending. First-contact resolution ~2-5ms (one-time per contact).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:25:14 -03:00
14 changed files with 113 additions and 349 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1034302344 /// WhatsApp Version: 2.3000.1034274421
message ADVDeviceIdentity { message ADVDeviceIdentity {
optional uint32 rawId = 1; optional uint32 rawId = 1;
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034382497]} {"version":[2,3000,1034279434]}
+1 -24
View File
@@ -56,33 +56,10 @@ export const PROCESSABLE_HISTORY_TYPES = [
// 6 hours in milliseconds // 6 hours in milliseconds
const SIX_HOURS_MS = 6 * 60 * 60 * 1000 const SIX_HOURS_MS = 6 * 60 * 60 * 1000
/**
* Resolves the default browser tuple from the BAILEYS_BROWSER env var.
* Default: Android companion (SMB_ANDROID) matches upstream PR #2201.
* Pair code auto-detects Android and falls back to Chrome in socket.ts.
*
* unset / 'android' Browsers.android('14')
* 'android:15' Browsers.android('15')
* 'chrome' / 'macos' Browsers.macOS('Chrome')
*/
const resolveDefaultBrowser = (): [string, string, string] => {
const env = process.env.BAILEYS_BROWSER?.trim().toLowerCase()
if (env === 'chrome' || env === 'macos') {
return Browsers.macOS('Chrome')
}
if (env?.startsWith('android:')) {
const apiLevel = env.split(':')[1] || '14'
return Browsers.android(apiLevel)
}
return Browsers.android('14')
}
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
version: version as WAVersion, version: version as WAVersion,
versionCheckIntervalMs: SIX_HOURS_MS, versionCheckIntervalMs: SIX_HOURS_MS,
browser: resolveDefaultBrowser(), browser: Browsers.macOS('Chrome'),
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
connectTimeoutMs: 20_000, connectTimeoutMs: 20_000,
keepAliveIntervalMs: 15_000, keepAliveIntervalMs: 15_000,
+13 -79
View File
@@ -302,25 +302,6 @@ export function makeLibSignalRepository(
// Promise instead of each spawning their own DB transactions. // Promise instead of each spawning their own DB transactions.
const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>() const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>()
// Resolve PN JID to its canonical LID JID for transaction locking.
// This prevents PN/LID race conditions where concurrent operations for the
// same logical contact acquire different mutex locks because one uses PN
// and the other uses LID. (Aligned with WABA behavior — all operations use LID internally.)
const resolveCanonicalJid = async(jid: string): Promise<string> => {
if (isAnyLidUser(jid)) {
return jid
}
if (isAnyPnUser(jid)) {
const lid = await lidMapping.getLIDForPN(jid)
if (lid) {
return lid
}
}
return jid
}
const repository: SignalRepositoryWithLIDStore = { const repository: SignalRepositoryWithLIDStore = {
decryptGroupMessage({ group, authorJid, msg }) { decryptGroupMessage({ group, authorJid, msg }) {
const senderName = jidToSignalSenderKeyName(group, authorJid) const senderName = jidToSignalSenderKeyName(group, authorJid)
@@ -415,24 +396,23 @@ export function makeLibSignalRepository(
return result return result
} }
// Use canonical JID (PN→LID resolved) as transaction key to prevent // If it's not a sync message, we need to ensure atomicity
// PN/LID race conditions on the same logical session. // For regular messages, we use a transaction to ensure atomicity
const canonicalJid = await resolveCanonicalJid(jid)
return parsedKeys.transaction(async () => { return parsedKeys.transaction(async () => {
return await doDecrypt() return await doDecrypt()
}, canonicalJid) }, jid)
}, },
async encryptMessage({ jid, data }) { async encryptMessage({ jid, data }) {
const addr = jidToSignalProtocolAddress(jid) const addr = jidToSignalProtocolAddress(jid)
const cipher = new libsignal.SessionCipher(storage, addr) const cipher = new libsignal.SessionCipher(storage, addr)
const canonicalJid = await resolveCanonicalJid(jid) // Use transaction to ensure atomicity
return parsedKeys.transaction(async () => { return parsedKeys.transaction(async () => {
const { type: sigType, body } = await cipher.encrypt(data) const { type: sigType, body } = await cipher.encrypt(data)
const type = sigType === 3 ? 'pkmsg' : 'msg' const type = sigType === 3 ? 'pkmsg' : 'msg'
return { type, ciphertext: Buffer.from(body, 'binary') } return { type, ciphertext: Buffer.from(body, 'binary') }
}, canonicalJid) }, jid)
}, },
async encryptGroupMessage({ group, meId, data }) { async encryptGroupMessage({ group, meId, data }) {
@@ -674,10 +654,9 @@ export function makeLibSignalRepository(
// Session exists (guaranteed from device discovery) // Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession) const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) { if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, retain PN session. // Queue for bulk update: copy to LID, delete from PN
// WABA retains both PN and LID sessions during migration to avoid
// No Session errors if messages arrive via PN before migration completes.
sessionUpdates[lidAddrStr] = fromSession.serialize() sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++ migratedCount++
} }
@@ -797,13 +776,6 @@ function signalStorage(
return id return id
} }
// Delayed PreKey deletion: grace period to handle race conditions
// where two pkmsg with the same preKeyId arrive nearly simultaneously.
// WABA deletes immediately (33ms), but we add a 5-min grace period
// because we can't handle "Invalid PreKey ID" errors at the native level.
const PREKEY_GRACE_PERIOD_MS = 5 * 60 * 1000 // 5 minutes
const pendingPreKeyDeletions = new Map<string, ReturnType<typeof setTimeout>>()
return { return {
loadSession: async (id: string) => { loadSession: async (id: string) => {
try { try {
@@ -836,26 +808,7 @@ function signalStorage(
} }
} }
}, },
removePreKey: (id: number) => { removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }),
const keyId = id.toString()
// Clear any existing timer for this key
const existing = pendingPreKeyDeletions.get(keyId)
if (existing) {
clearTimeout(existing)
}
// Schedule deletion after grace period
const timer = setTimeout(async () => {
pendingPreKeyDeletions.delete(keyId)
try {
await keys.set({ 'pre-key': { [id]: null } })
} catch {
// Keystore may be destroyed if connection closed — safe to ignore
}
}, PREKEY_GRACE_PERIOD_MS)
pendingPreKeyDeletions.set(keyId, timer)
},
loadSignedPreKey: () => { loadSignedPreKey: () => {
const key = creds.signedPreKey const key = creds.signedPreKey
return { return {
@@ -945,24 +898,14 @@ function signalStorage(
// IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices // IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices
const previousFingerprint = generateKeyFingerprint(existingKey) const previousFingerprint = generateKeyFingerprint(existingKey)
// Delete old session and save new identity key atomically. // Delete old session and save new identity key atomically
// Store identity in BOTH LID and PN addresses (WABA stores in both
// recipient_account_type=0 and type=1 with CONFLICT_REPLACE).
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
await keys.set({ await keys.set({
session: { [wireJid]: null }, session: { [wireJid]: null },
'identity-key': identityUpdates 'identity-key': { [wireJid]: identityKey }
}) })
// Update cache for both addresses // Update cache
identityKeyCache.set(wireJid, identityKey) identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics // Record metrics
metrics.signalIdentityChanges?.inc({ type: 'changed' }) metrics.signalIdentityChanges?.inc({ type: 'changed' })
@@ -998,19 +941,10 @@ function signalStorage(
if (!existingKey) { if (!existingKey) {
// NEW CONTACT - Trust On First Use (TOFU) // NEW CONTACT - Trust On First Use (TOFU)
// Store in both LID and PN addresses (aligned with WABA dual identity storage) await keys.set({ 'identity-key': { [wireJid]: identityKey } })
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
await keys.set({ 'identity-key': identityUpdates }) // Update cache
// Update cache for both addresses
identityKeyCache.set(wireJid, identityKey) identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics // Record metrics
metrics.signalIdentityChanges?.inc({ type: 'new' }) metrics.signalIdentityChanges?.inc({ type: 'new' })
+9 -137
View File
@@ -31,7 +31,6 @@ import {
aesDecryptCTR, aesDecryptCTR,
aesEncryptGCM, aesEncryptGCM,
cleanMessage, cleanMessage,
cleanupCorruptedSession,
Curve, Curve,
decodeMediaRetryNode, decodeMediaRetryNode,
decodeMessageNode, decodeMessageNode,
@@ -42,7 +41,6 @@ import {
encodeSignedDeviceIdentity, encodeSignedDeviceIdentity,
extractAddressingContext, extractAddressingContext,
getCallStatusFromNode, getCallStatusFromNode,
getDecryptionJid,
getHistoryMsg, getHistoryMsg,
getNextPreKeys, getNextPreKeys,
getStatusFromReceiptType, getStatusFromReceiptType,
@@ -69,7 +67,7 @@ import {
recordMessageReceived, recordMessageReceived,
recordMessageRetry recordMessageRetry
} from '../Utils/prometheus-metrics.js' } from '../Utils/prometheus-metrics.js'
import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils' import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
import { import {
areJidsSameUser, areJidsSameUser,
type BinaryNode, type BinaryNode,
@@ -164,12 +162,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts' const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts'
const tcTokenKnownJids = new Set<string>() const tcTokenKnownJids = new Set<string>()
const tcTokenRetriedMsgIds = new Set<string>() const tcTokenRetriedMsgIds = new Set<string>()
// Deduplicates retry requests per JID within a short window.
// When a burst of Bad MAC errors arrives for the same contact,
// only the first retry request is sent — the peer resends everything
// with a single pkmsg, avoiding the close-session cascade.
const retryRequestActiveJids = new Set<string>()
let tcTokenIndexSaveTimer: ReturnType<typeof setTimeout> | undefined let tcTokenIndexSaveTimer: ReturnType<typeof setTimeout> | undefined
let lastTcTokenPruneTs = 0 let lastTcTokenPruneTs = 0
@@ -1217,50 +1209,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const { key: msgKey } = fullMessage const { key: msgKey } = fullMessage
const msgId = msgKey.id! const msgId = msgKey.id!
// Per-JID deduplication: when multiple messages from the same contact
// fail with Bad MAC simultaneously, only send ONE retry request.
// The peer will resend all failed messages when it receives the retry receipt.
// For group messages, scope by participant (each participant has its own Signal session).
const retryDedupeJid = msgKey.participant
? jidNormalizedUser(msgKey.participant)
: jidNormalizedUser(node.attrs.from!)
if (retryRequestActiveJids.has(retryDedupeJid)) {
logger.debug(
{ fromJid: retryDedupeJid, msgId },
'Skipping duplicate retry request — already in-flight for this JID'
)
return
}
if (messageRetryManager) { if (messageRetryManager) {
// Check if we've exceeded max retries using the new system // Check if we've exceeded max retries using the new system
if (messageRetryManager.hasExceededMaxRetries(msgId)) { if (messageRetryManager.hasExceededMaxRetries(msgId)) {
logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing') logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing')
messageRetryManager.markRetryFailed(msgId) messageRetryManager.markRetryFailed(msgId)
recordMessageFailure('retry', 'max_retries_reached') recordMessageFailure('retry', 'max_retries_reached')
// Safety net: clean up corrupted sessions only after all retries exhausted.
// This avoids the cascading delete loop that occurs when cleanup runs
// on every Bad MAC in the hot path (decode-wa-message.ts).
// The Signal Protocol recovers naturally via retry+pkmsg for most cases;
// this cleanup only runs as a last resort.
// For group messages, use participant JID (Signal sessions are per-participant, not per-group).
if (autoCleanCorrupted) {
const senderJid = msgKey.participant ? jidNormalizedUser(msgKey.participant) : jidNormalizedUser(node.attrs.from!)
try {
const decryptionJid = await getDecryptionJid(senderJid, signalRepository)
const deletedCount = await cleanupCorruptedSession(decryptionJid, signalRepository, logger)
if (deletedCount > 0) {
logger.info(
{ msgId, jid: decryptionJid, targetedDevices: deletedCount },
`🔄 Session cleanup (retry exhausted) | Targeted: ${deletedCount} devices`
)
}
} catch (cleanupErr) {
logger.warn({ msgId, senderJid, err: cleanupErr }, 'Failed to cleanup session after retry exhaustion')
}
}
return return
} }
@@ -1279,18 +1233,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ retryCount, msgId }, 'reached retry limit, clearing') logger.debug({ retryCount, msgId }, 'reached retry limit, clearing')
await msgRetryCache.del(key) await msgRetryCache.del(key)
recordMessageFailure('retry', 'max_retries_reached') recordMessageFailure('retry', 'max_retries_reached')
// Safety net cleanup (same as new system above)
if (autoCleanCorrupted) {
const senderJid = msgKey.participant ? jidNormalizedUser(msgKey.participant) : jidNormalizedUser(node.attrs.from!)
try {
const decryptionJid = await getDecryptionJid(senderJid, signalRepository)
await cleanupCorruptedSession(decryptionJid, signalRepository, logger)
} catch (cleanupErr) {
logger.warn({ msgId, senderJid, err: cleanupErr }, 'Failed to cleanup session after retry exhaustion')
}
}
return return
} }
@@ -1299,11 +1241,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
recordMessageRetry('retry') recordMessageRetry('retry')
} }
// Register dedup AFTER early-return checks so that max-retries paths
// don't block subsequent messages from the same JID for 5 seconds.
retryRequestActiveJids.add(retryDedupeJid)
setTimeout(() => retryRequestActiveJids.delete(retryDedupeJid), 5_000)
const key = `${msgId}:${msgKey?.participant}` const key = `${msgId}:${msgKey?.participant}`
const retryCount = (await msgRetryCache.get<number>(key)) || 1 const retryCount = (await msgRetryCache.get<number>(key)) || 1
@@ -1314,7 +1251,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let shouldRecreateSession = false let shouldRecreateSession = false
let recreateReason = '' let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) { if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
try { try {
// Check if we have a session with this JID // Check if we have a session with this JID
const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid) const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
@@ -1453,7 +1390,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}) })
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget // When a session is refreshed (identity change), re-issue tctoken fire-and-forget
// WABA Android: reissue stores senderTimestamp + realIssueTimestamp after IQ success
if (result.action === 'session_refreshed') { if (result.action === 'session_refreshed') {
const normalizedJid = jidNormalizedUser(from) const normalizedJid = jidNormalizedUser(from)
resolveTcTokenJid(normalizedJid, getLIDForPN) resolveTcTokenJid(normalizedJid, getLIDForPN)
@@ -1463,36 +1399,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) { if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
const senderTs = unixTimestampSeconds() const senderTs = unixTimestampSeconds()
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' }) logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
getPrivacyTokens([normalizedJid], senderTs) getPrivacyTokens([normalizedJid], senderTs).catch(err => {
.then(async (iqResult) => { logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
await storeTcTokensFromIqResult({ })
result: iqResult,
fallbackJid: normalizedJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
// Persist senderTimestamp + realIssueTimestamp after IQ success
const currentData = await authState.keys.get('tctoken', [tcJid])
const currentEntry = currentData[tcJid]
await authState.keys.set({
tctoken: {
[tcJid]: {
...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: senderTs,
realIssueTimestamp: 0
}
}
})
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'session_refreshed' })
})
.catch(err => {
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
})
} }
}) })
.catch(() => { .catch(() => {
@@ -1940,10 +1849,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
[storageJid]: { [storageJid]: {
...existing, ...existing,
token: Buffer.from(content), token: Buffer.from(content),
timestamp, timestamp
// WABA Android: resets real_issue_timestamp when a new incoming token arrives
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
realIssueTimestamp: null
} }
} }
}) })
@@ -2033,7 +1939,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let shouldRecreateSession = false let shouldRecreateSession = false
let recreateReason = '' let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) { if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
try { try {
const sessionId = signalRepository.jidToSignalProtocolAddress(participant) const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
@@ -2265,6 +2171,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
authState.creds.me!.lid || '', authState.creds.me!.lid || '',
signalRepository, signalRepository,
logger, logger,
autoCleanCorrupted
) )
const alt = msg.key.participantAlt || msg.key.remoteJidAlt const alt = msg.key.participantAlt || msg.key.remoteJidAlt
@@ -2854,24 +2761,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const jid = jidNormalizedUser(attrs.from) const jid = jidNormalizedUser(attrs.from)
logTcToken('error_463', { jid, msgId }) logTcToken('error_463', { jid, msgId })
// WABA Android: error 463 triggers getPrivacyTokens() fire-and-forget
// to ensure token is available for the retry below
getPrivacyTokens([jid])
.then(async (result) => {
await storeTcTokensFromIqResult({
result,
fallbackJid: jid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('fetched', { jid, reason: 'error_463' })
})
.catch(() => { /* fire-and-forget */ })
// Single-retry: wait 1.5s for the server's tctoken notification to arrive, // Single-retry: wait 1.5s for the server's tctoken notification to arrive,
// then resend. A Set prevents infinite retry loops. // then resend. A Set prevents infinite retry loops.
// Composite key (jid:msgId) ensures retries are isolated per destination. // Composite key (jid:msgId) ensures retries are isolated per destination.
@@ -2907,24 +2796,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return return
} }
} else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) { } else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
const jid479 = jidNormalizedUser(attrs.from) logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
logTcToken('error_479', { jid: jid479, msgId: attrs.id })
// WABA Android: error 479 (SmaxInvalid) also triggers token re-fetch
getPrivacyTokens([jid479])
.then(async (result) => {
await storeTcTokensFromIqResult({
result,
fallbackJid: jid479,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('fetched', { jid: jid479, reason: 'error_479' })
})
.catch(() => { /* fire-and-forget */ })
} else { } else {
logger.warn({ attrs }, 'received error in ack') logger.warn({ attrs }, 'received error in ack')
} }
+1 -4
View File
@@ -1611,8 +1611,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Persist senderTimestamp unconditionally — WA Web stores it in the chat table // Persist senderTimestamp unconditionally — WA Web stores it in the chat table
// regardless of whether a token exists. Spread preserves token+timestamp if present. // regardless of whether a token exists. Spread preserves token+timestamp if present.
// WABA Android: INSERT INTO wa_trusted_contacts_send (jid, sent_tc_token_timestamp, real_issue_timestamp)
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server
const currentData = await authState.keys.get('tctoken', [tcTokenJid]) const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid] const currentEntry = currentData[tcTokenJid]
await authState.keys.set({ await authState.keys.set({
@@ -1620,8 +1618,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
[tcTokenJid]: { [tcTokenJid]: {
...currentEntry, ...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0), token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: issueTimestamp, senderTimestamp: issueTimestamp
realIssueTimestamp: 0
} }
} }
}) })
+6 -25
View File
@@ -38,7 +38,7 @@ import {
signedKeyPair, signedKeyPair,
xmppSignedPreKey xmppSignedPreKey
} from '../Utils' } from '../Utils'
import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils' import { getPlatformId } from '../Utils/browser-utils'
import { import {
CircuitBreaker, CircuitBreaker,
CircuitOpenError, CircuitOpenError,
@@ -634,6 +634,8 @@ export const makeSocket = (config: SocketConfig) => {
} }
helloMsg = proto.HandshakeMessage.fromObject(helloMsg) helloMsg = proto.HandshakeMessage.fromObject(helloMsg)
logger.info({ browser, helloMsg }, 'connected to WA')
const init = proto.HandshakeMessage.encode(helloMsg).finish() const init = proto.HandshakeMessage.encode(helloMsg).finish()
const result = await awaitNextMessage<Uint8Array>(init) const result = await awaitNextMessage<Uint8Array>(init)
@@ -1350,25 +1352,6 @@ export const makeSocket = (config: SocketConfig) => {
id: jidEncode(phoneNumber, 's.whatsapp.net'), id: jidEncode(phoneNumber, 's.whatsapp.net'),
name: '~' name: '~'
} }
// Pair code companion_platform_id must be Chrome (1) when using Android
// browser preset. ANDROID_PHONE (16) causes silent timeout (server ignores),
// UWP (21) causes "cannot connect device" rejection. Only Chrome (1) works
// for pair code via web protocol (WA\x06\x03). The device still appears as
// "Android" in linked devices because DeviceProps.platformType=ANDROID_PHONE
// is set separately in the registration node.
const isAndroid = isAndroidBrowser(browser)
const pairPlatformId = isAndroid ? getPlatformId('Chrome') : getPlatformId(browser[1])
const pairPlatformDisplay = isAndroid ? 'Chrome (Mac OS)' : `${browser[1]} (${browser[0]})`
logger.info({
pairCode: pairingCode,
jid: authState.creds.me.id,
companionPlatformId: pairPlatformId,
companionPlatformDisplay: pairPlatformDisplay,
isAndroid,
}, `pair code requested | companion: ${pairPlatformDisplay} | ${isAndroid ? 'android override -> Chrome' : 'native platform'}`)
ev.emit('creds.update', authState.creds) ev.emit('creds.update', authState.creds)
await sendNode({ await sendNode({
tag: 'iq', tag: 'iq',
@@ -1401,12 +1384,12 @@ export const makeSocket = (config: SocketConfig) => {
{ {
tag: 'companion_platform_id', tag: 'companion_platform_id',
attrs: {}, attrs: {},
content: pairPlatformId content: getPlatformId(browser[1])
}, },
{ {
tag: 'companion_platform_display', tag: 'companion_platform_display',
attrs: {}, attrs: {},
content: pairPlatformDisplay content: `${browser[1]} (${browser[0]})`
}, },
{ {
tag: 'link_code_pairing_nonce', tag: 'link_code_pairing_nonce',
@@ -1536,9 +1519,7 @@ export const makeSocket = (config: SocketConfig) => {
}) })
// login complete // login complete
ws.on('CB:success', async (node: BinaryNode) => { ws.on('CB:success', async (node: BinaryNode) => {
const isAndroid = isAndroidBrowser(browser) logger.info('opened connection to WA')
const phoneId = authState.creds.me?.id?.split(':')[0]?.split('@')[0] || 'new session'
logger.info(`${isAndroid ? '\uD83D\uDCF1' : '\uD83D\uDDA5\uFE0F'} Connected to WA | ${phoneId} | platform: ${isAndroid ? 'SMB_ANDROID' : 'MACOS'} | device: ${isAndroid ? 'Android' : 'Desktop'} | platformType: ${isAndroid ? 'ANDROID_PHONE' : 'CHROME'}`)
clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try
ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } }) ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } })
+1 -1
View File
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
'app-state-sync-version': LTHashState 'app-state-sync-version': LTHashState
'lid-mapping': string 'lid-mapping': string
'device-list': string[] 'device-list': string[]
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number; realIssueTimestamp?: number | null } tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
/** Identity key for Signal Protocol - used for detecting contact reinstalls */ /** Identity key for Signal Protocol - used for detecting contact reinstalls */
'identity-key': Uint8Array 'identity-key': Uint8Array
} }
-1
View File
@@ -23,7 +23,6 @@ export type BrowsersMap = {
baileys(browser: string): [string, string, string] baileys(browser: string): [string, string, string]
windows(browser: string): [string, string, string] windows(browser: string): [string, string, string]
appropriate(browser: string): [string, string, string] appropriate(browser: string): [string, string, string]
android(apiLevel: string): [string, string, string]
} }
export enum DisconnectReason { export enum DisconnectReason {
+3 -20
View File
@@ -94,13 +94,7 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
} }
} }
const map = new Map(entries) return new Map(entries)
// ANDROID → ANDROID_PHONE alias (DeviceProps.PlatformType has ANDROID_PHONE but not ANDROID)
if (!map.has('ANDROID') && map.has('ANDROID_PHONE')) {
map.set('ANDROID', map.get('ANDROID_PHONE')!)
}
return map
})() })()
// ============================================================ // ============================================================
@@ -337,8 +331,7 @@ export const Browsers: BrowsersMap = {
macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS], macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS],
windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows], windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows],
baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys], baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys],
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()], appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()]
android: (apiLevel: string): [string, string, string] => [apiLevel, 'Android', '']
} as const } as const
/** /**
@@ -385,7 +378,7 @@ export const getPlatformId = (browser: unknown): string => {
* properties like 'toString' or 'constructor'. * properties like 'toString' or 'constructor'.
* *
* @param value - Value to check * @param value - Value to check
* @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate', 'android') * @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate')
* *
* @example * @example
* isValidBrowserPreset('ubuntu') // true * isValidBrowserPreset('ubuntu') // true
@@ -398,16 +391,6 @@ export const getPlatformId = (browser: unknown): string => {
* const config = Browsers[userInput]('MyApp') * const config = Browsers[userInput]('MyApp')
* } * }
*/ */
/**
* Checks if the browser tuple represents an Android companion device.
*
* @param browser - Browser tuple [os, platform, version]
* @returns True if platform is 'Android' (case-insensitive)
*/
export const isAndroidBrowser = (browser: [string, string, string]): boolean => {
return browser[1]?.toUpperCase() === 'ANDROID'
}
export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => { export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value) return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
} }
+68 -18
View File
@@ -14,6 +14,7 @@ import {
isJidStatusBroadcast, isJidStatusBroadcast,
isLidUser, isLidUser,
isPnUser, isPnUser,
jidDecode
// transferDevice // transferDevice
} from '../WABinary' } from '../WABinary'
import { unpadRandomMax16 } from './generics' import { unpadRandomMax16 } from './generics'
@@ -275,6 +276,7 @@ export const decryptMessageNode = (
meLid: string, meLid: string,
repository: SignalRepositoryWithLIDStore, repository: SignalRepositoryWithLIDStore,
logger: ILogger, logger: ILogger,
autoCleanCorrupted = true
) => { ) => {
const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid) const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid)
return { return {
@@ -420,19 +422,34 @@ export const decryptMessageNode = (
if (isRetryExhausted) { if (isRetryExhausted) {
logger.error( logger.error(
errorContext, errorContext,
`⚠️ Session corrupted after ${err.attempts} attempts. Retry+pkmsg flow will recover.` `⚠️ Session corrupted and recovery failed after ${err.attempts} attempts. Auto-cleanup will attempt to recover.`
) )
} else { } else {
// First occurrence - log as warning since auto-recovery will attempt // First occurrence - log as warning since auto-recovery will attempt
logger.warn(errorContext, '⚠️ Corrupted session detected - attempting auto-recovery') logger.warn(errorContext, '⚠️ Corrupted session detected - attempting auto-recovery')
} }
// Session cleanup is deferred to retry exhaustion (safety net). // Automatic cleanup of corrupted session (if enabled)
// The Signal Protocol handles recovery naturally via retry+pkmsg: // eslint-disable-next-line max-depth
// Bad MAC -> retry receipt -> sender re-sends as pkmsg -> new session. if (autoCleanCorrupted) {
// Deleting sessions here (hot path) causes cascading failures when // eslint-disable-next-line max-depth
// multiple messages from the same contact arrive simultaneously. try {
// See: messages-recv.ts sendRetryRequest() for deferred cleanup. const deletedCount = await cleanupCorruptedSession(decryptionJid, repository, logger)
// Mask only user portion of JID for privacy (preserve domain info)
const { user, server } = jidDecode(decryptionJid) || {}
const maskedUser =
user && user.length > 8 ? `${user.substring(0, 4)}****${user.substring(user.length - 4)}` : user
const maskedJid = maskedUser && server ? `${maskedUser}@${server}` : decryptionJid
logger.info(
{ jid: decryptionJid, maskedJid, targetedDevices: deletedCount },
`🔄 Session Reset | JID: ${maskedJid} | Targeted: ${deletedCount} devices | Will recreate on next message`
)
} catch (cleanupErr) {
logger.error({ decryptionJid, err: cleanupErr }, '❌ Failed to cleanup corrupted session')
}
}
} else if (isSessionRecord) { } else if (isSessionRecord) {
// Session record errors are transient - retry should handle them // Session record errors are transient - retry should handle them
// eslint-disable-next-line max-depth // eslint-disable-next-line max-depth
@@ -487,20 +504,53 @@ export function isCorruptedSessionError(error: any): boolean {
} }
/** /**
* Clean up corrupted session for a specific device JID. * Clean up corrupted session by deleting all device sessions for a JID
* WABA behavior: DELETE sessions WHERE recipient_id=? AND device_id=? * Signal Protocol will automatically recreate the session on next message
* Only deletes the exact device that was corrupted, not all devices.
*
* NOTE: This should NOT be called on every Bad MAC error (hot path).
* Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
* Only call this as a safety net when retries are exhausted.
*/ */
export async function cleanupCorruptedSession( async function cleanupCorruptedSession(
jid: string, jid: string,
repository: SignalRepositoryWithLIDStore, repository: SignalRepositoryWithLIDStore,
logger: ILogger logger: ILogger
): Promise<number> { ): Promise<number> {
await repository.deleteSession([jid]) const { user, device } = jidDecode(jid) || {}
logger.info({ jid }, 'Cleaned up corrupted session for specific device') if (!user) {
return 1 logger.warn({ jid }, 'Cannot cleanup session - invalid JID')
return 0
}
// Build list of JIDs to delete (primary + secondary devices)
const jidsToDelete: string[] = []
// Determine domain type correctly (handle hosted JIDs)
// JID formats:
// - PN: user@s.whatsapp.net
// - LID: user@lid
// - Hosted PN: user@hosted
// - Hosted LID: user@hosted.lid
const isLID = jid.endsWith('@lid') || jid.endsWith('@hosted.lid')
const isHosted = jid.includes('@hosted')
let domain: string
if (isLID) {
domain = isHosted ? 'hosted.lid' : 'lid'
} else {
domain = isHosted ? 'hosted' : 's.whatsapp.net'
}
// Primary device (0)
jidsToDelete.push(`${user}@${domain}`)
// Secondary devices (1-5 common range for Web/Desktop/etc)
for (let i = 1; i <= 5; i++) {
jidsToDelete.push(`${user}:${i}@${domain}`)
}
// If specific device was identified and > 5, ensure it's included
if (device !== undefined && device > 5) {
jidsToDelete.push(`${user}:${device}@${domain}`)
}
await repository.deleteSession(jidsToDelete)
return jidsToDelete.length
} }
+4 -22
View File
@@ -211,33 +211,15 @@ export class MessageRetryManager {
} }
} }
// MAC errors require session recreation, but rate-limited to prevent loops. // MAC errors require IMMEDIATE session recreation regardless of history
// When multiple messages fail with Bad MAC simultaneously, only the first // This handles the case where contact reinstalled WhatsApp (identity key changed)
// should trigger recreation; subsequent ones within the cooldown window
// piggyback on the same new session established by the retry+pkmsg flow.
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) { if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
const now = Date.now() this.sessionRecreateHistory.set(jid, Date.now())
const prevTime = this.sessionRecreateHistory.get(jid)
const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
this.logger.debug(
{ jid, errorCode: reasonName, msSinceLast: now - prevTime },
'MAC error session recreation skipped — cooldown active'
)
return {
reason: '',
recreate: false
}
}
this.sessionRecreateHistory.set(jid, now)
this.statistics.sessionRecreations++ this.statistics.sessionRecreations++
const reasonName = RetryReason[errorCode] || `code_${errorCode}` const reasonName = RetryReason[errorCode] || `code_${errorCode}`
metrics.signalMacErrors?.inc({ action: 'session_recreation' }) metrics.signalMacErrors?.inc({ action: 'session_recreation' })
metrics.signalSessionRecreations?.inc({ reason: 'mac_error' }) metrics.signalSessionRecreations?.inc({ reason: 'mac_error' })
this.logger.warn({ jid, errorCode: reasonName }, 'MAC error detected, recreating session') this.logger.warn({ jid, errorCode: reasonName }, 'MAC error detected, forcing immediate session recreation')
return { return {
reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`, reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`,
recreate: true recreate: true
+1 -4
View File
@@ -159,10 +159,7 @@ export async function storeTcTokensFromIqResult({
[storageJid]: { [storageJid]: {
...existingEntry, ...existingEntry,
token: Buffer.from(tokenNode.content), token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t, timestamp: tokenNode.attrs.t
// WABA Android: resets real_issue_timestamp to null when storing a new token
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
realIssueTimestamp: null
} }
} }
}) })
+4 -12
View File
@@ -14,12 +14,6 @@ import { encodeBigEndian } from './generics'
import { createSignalIdentity } from './signal' import { createSignalIdentity } from './signal'
const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => { const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
// Always use MACOS/Desktop identity for UserAgent — we connect via web
// protocol (WA\x06\x03) so the server expects a web-like UserAgent.
// Using SMB_ANDROID here causes pair code registration to fail with
// "não é possível conectar" even though the phone shows the confirmation.
// Android identity is only set in DeviceProps (registration node) which
// determines the display name in "Linked Devices".
return { return {
appVersion: { appVersion: {
primary: config.version[0], primary: config.version[0],
@@ -32,6 +26,7 @@ const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
device: 'Desktop', device: 'Desktop',
osBuildNumber: '0.1', osBuildNumber: '0.1',
localeLanguageIso6391: 'en', localeLanguageIso6391: 'en',
mnc: '000', mnc: '000',
mcc: '000', mcc: '000',
localeCountryIso31661Alpha2: config.countryCode localeCountryIso31661Alpha2: config.countryCode
@@ -60,10 +55,11 @@ const getClientPayload = (config: SocketConfig) => {
const payload: proto.IClientPayload = { const payload: proto.IClientPayload = {
connectType: proto.ClientPayload.ConnectType.WIFI_UNKNOWN, connectType: proto.ClientPayload.ConnectType.WIFI_UNKNOWN,
connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED, connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED,
userAgent: getUserAgent(config), userAgent: getUserAgent(config)
webInfo: getWebInfo(config)
} }
payload.webInfo = getWebInfo(config)
return payload return payload
} }
@@ -88,10 +84,6 @@ export const generateLoginNode = (userJid: string, config: SocketConfig): proto.
const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => { const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => {
const platformType = platform.toUpperCase() const platformType = platform.toUpperCase()
if (platformType === 'ANDROID') {
return proto.DeviceProps.PlatformType.ANDROID_PHONE
}
return ( return (
proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] || proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] ||
proto.DeviceProps.PlatformType.CHROME proto.DeviceProps.PlatformType.CHROME