Compare commits

..

1 Commits

Author SHA1 Message Date
Renato Alcara 431e48d147 fix(tctoken): align lifecycle with WABA Android behavior
- Error 463 (MissingTcToken): add getPrivacyTokens() re-fetch before retry
- Error 479 (SmaxInvalid): add getPrivacyTokens() re-fetch (fire-and-forget)
- Add realIssueTimestamp field matching wa_trusted_contacts_send schema
- Session refresh reissue: store IQ result + persist senderTimestamp/realIssueTimestamp

Based on Frida capture analysis of WhatsApp Business Android (com.whatsapp.w4b).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:50:22 -03:00
6 changed files with 62 additions and 90 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1034427372 /// WhatsApp Version: 2.3000.1034302344
message ADVDeviceIdentity { message ADVDeviceIdentity {
optional uint32 rawId = 1; optional uint32 rawId = 1;
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1038487394]} {"version":[2,3000,1034382497]}
+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' })
+2 -2
View File
@@ -1314,7 +1314,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)
@@ -2033,7 +2033,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)
+44 -6
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'
@@ -487,9 +488,8 @@ 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). * 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). * Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
@@ -500,7 +500,45 @@ export async function cleanupCorruptedSession(
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
} }
+1 -1
View File
@@ -218,7 +218,7 @@ export class MessageRetryManager {
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) { if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
const now = Date.now() const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid) const prevTime = this.sessionRecreateHistory.get(jid)
const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster const MAC_ERROR_COOLDOWN_MS = 10_000 // 10 seconds
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) { if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
const reasonName = RetryReason[errorCode] || `code_${errorCode}` const reasonName = RetryReason[errorCode] || `code_${errorCode}`