feat(tctoken): complete lifecycle (TIER 1 + 2 + 3 of upstream PR #2339)
Backports the 4 critical/major gaps the audit identified vs Baileys PR #2339, surgically and without touching carousel, buttons, lists, LID/PN normalization, Bad MAC handling or app-state-sync code. TIER 1 — eliminates production error 463 in two scenarios: - onBeforeSessionRefresh callback (identity-change-handler.ts): invoked BEFORE assertSessions for an existing-session identity change. Skipped for self / companion / debounced / offline paths. - reissueTcTokenAfterIdentityChange (messages-recv.ts): fire-and- forget, runs IN PARALLEL with the session refresh (not after) and preserves the existing senderTimestamp so the contact gets a fresh token in the same bucket window. Replaces the fork's previous post-refresh reissue, which raced with the next outbound send. - storeTcTokensFromHistorySync (process-message.ts): extracts tcToken / tcTokenTimestamp / tcTokenSenderTimestamp from history- sync chats and persists them BEFORE messaging-history.set fires, so multi-device login doesn't lose tokens. Strict > monotonicity (matches upstream). TIER 2 — server AB-prop gating: - serverProps in chats.ts: parses 10518 (privacy_token_sending_on_ all_1_on_1_messages), 9666 (profile_scraping_privacy_token_in_ photo_iq), 14303 (lid_trusted_token_issue_to_lid). Defaults match WA Web (true / true / false). Exported in the socket return so messages-send/messages-recv can read it. - profilePictureUrl gates inclusion on serverProps.profilePicPrivacyToken. - 1:1 send gates attach on serverProps.privacyTokenOn1to1. - resolveIssuanceJid (tc-token-utils.ts): routes issuance to LID vs PN based on AB prop 14303. Used by both the post-send fire- and-forget and the identity-change reissue. TIER 3 — defensive hardening: - isRegularUser (tc-token-utils.ts): Wid.isRegularUser() port that rejects PSA WID '0', bot phone patterns (1313555XXXX / 131655500XX) and MetaAI (@bot). storeTcTokensFromIqResult drops malformed notifications before writing. Send-side issuance also gates on PSA / bot / protocol-message exclusions (matches WA Web's TcTokenChatAction). - inFlightTcTokenIssuance Set (messages-send.ts): dedupes fire-and-forget issuance when several rapid sends to the same contact happen before senderTimestamp persists. Distinct from the existing tcTokenFetchingJids (which dedupes inbound fetches). - TC_TOKEN_INDEX_KEY exported from tc-token-utils.ts and re-used in messages-recv.ts (was previously inlined as a separate local const — risk of divergence on rename). Same value '__index'. - readTcTokenIndex / buildMergedTcTokenIndexWrite: cross-session prune index helpers so issuance / history-sync / pruner all merge with the persisted set instead of clobbering each other. Audit findings explicitly addressed: - TC_TOKEN_INDEX_KEY duplication: unified via import. - Index write race (prune vs issuance): documented; worst case is a one-cycle delay of pruning, no data loss. - Identity-change reissue + post-send issuance double-fire: bounded by bucket coalescing — both use the same senderTimestamp window so even if both IQs go out, the persisted state converges. Tests: - identity-change-handling: 2 new cases covering onBeforeSessionRefresh ordering (fires BEFORE assertSessions) and skip behavior on no_identity / offline / self. - tc-token: 32 new cases across isRegularUser (PSA / bot / MetaAI / hosted), resolveIssuanceJid (AB prop 14303 ON/OFF, missing mappings), TC_TOKEN_INDEX_KEY ('__index' frozen), readTcTokenIndex (corruption / empty / sentinel filter), buildMergedTcTokenIndexWrite (merge / sentinel drop / empty), storeTcTokensFromIqResult gating (PSA / bot / MetaAI rejection, fallbackJid storage routing, monotonicity). Suite: 35/35, 821/821 passing (+115 vs baseline 706). Customizations untouched: zero diff in src/Utils/messages.ts (carousel generators), src/Socket/groups.ts, WAProto/*. Confirmed via grep for carousel/button/interactive/nativeFlow/list/biz/album in the modified files — no matches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+69
-51
@@ -73,7 +73,13 @@ import {
|
||||
recordMessageReceived,
|
||||
recordMessageRetry
|
||||
} from '../Utils/prometheus-metrics.js'
|
||||
import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils'
|
||||
import {
|
||||
isTcTokenExpired,
|
||||
resolveIssuanceJid,
|
||||
resolveTcTokenJid,
|
||||
storeTcTokensFromIqResult,
|
||||
TC_TOKEN_INDEX_KEY
|
||||
} from '../Utils/tc-token-utils'
|
||||
import {
|
||||
areJidsSameUser,
|
||||
type BinaryNode,
|
||||
@@ -164,7 +170,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
let sendActiveReceipts = false
|
||||
|
||||
// ======= tctoken index tracking for cross-session pruning =======
|
||||
const TC_TOKEN_INDEX_KEY = '__index'
|
||||
// TC_TOKEN_INDEX_KEY is imported from tc-token-utils so the value stays in sync
|
||||
// with messages-send/process-message writes (avoids string drift on rename).
|
||||
// Race note: this prune-driven index write may interleave with
|
||||
// buildMergedTcTokenIndexWrite calls from issuance/history-sync paths. Worst
|
||||
// case: a JID resurrected by a stale read gets pruned again on the next 24h
|
||||
// sweep — no data loss, just one extra cycle.
|
||||
const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts'
|
||||
const tcTokenKnownJids = new Set<string>()
|
||||
const tcTokenRetriedMsgIds = new Set<string>()
|
||||
@@ -1434,6 +1445,58 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
}, authState?.creds?.me?.id || 'sendRetryRequest')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget tctoken re-issuance after a peer's device identity changed.
|
||||
* Mirrors WAWebSendTcTokenWhenDeviceIdentityChange — runs in PARALLEL with the
|
||||
* session refresh (not after it).
|
||||
*
|
||||
* Why parallel and not sequential:
|
||||
* - WA Web invokes this BEFORE assertSessions to maximise the chance the contact
|
||||
* has a fresh tctoken by the time the next outbound send executes.
|
||||
* - Running after assertSessions (the fork's previous behaviour) racets with the
|
||||
* next send and risks error 463 when the contact reinstalls and the user replies
|
||||
* immediately afterwards.
|
||||
*
|
||||
* Gated on `entry.senderTimestamp` (we previously issued a token to this peer in the
|
||||
* current bucket window). Preserves the existing senderTimestamp instead of issuing
|
||||
* a fresh one — keeps WA Web's bucket coalescing semantics: same token, same window.
|
||||
*/
|
||||
const reissueTcTokenAfterIdentityChange = (from: string): void => {
|
||||
void (async () => {
|
||||
const normalizedJid = jidNormalizedUser(from)
|
||||
const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN)
|
||||
const tcTokenData = await authState.keys.get('tctoken', [tcJid])
|
||||
const senderTs = tcTokenData?.[tcJid]?.senderTimestamp
|
||||
|
||||
if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) {
|
||||
return
|
||||
}
|
||||
|
||||
logTcToken('reissue', { jid: normalizedJid, reason: 'identity_changed', senderTimestamp: senderTs })
|
||||
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
|
||||
const issueJid = await resolveIssuanceJid(
|
||||
normalizedJid,
|
||||
sock.serverProps.lidTrustedTokenIssueToLid,
|
||||
getLIDForPN,
|
||||
getPNForLID
|
||||
)
|
||||
const result = await getPrivacyTokens([issueJid], senderTs)
|
||||
await storeTcTokensFromIqResult({
|
||||
result,
|
||||
fallbackJid: tcJid,
|
||||
keys: authState.keys,
|
||||
getLIDForPN,
|
||||
onNewJidStored: storedJid => {
|
||||
tcTokenKnownJids.add(storedJid)
|
||||
scheduleTcTokenIndexSave()
|
||||
}
|
||||
})
|
||||
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'identity_changed' })
|
||||
})().catch(err => {
|
||||
logTcToken('reissue_fail', { jid: from, error: err?.message })
|
||||
})
|
||||
}
|
||||
|
||||
const handleEncryptNotification = async (node: BinaryNode) => {
|
||||
const from = node.attrs.from
|
||||
if (from === S_WHATSAPP_NET) {
|
||||
@@ -1453,57 +1516,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
validateSession: signalRepository.validateSession,
|
||||
assertSessions,
|
||||
debounceCache: identityAssertDebounce,
|
||||
logger
|
||||
logger,
|
||||
// Fire reissue in parallel with the session refresh, NOT after it.
|
||||
// See reissueTcTokenAfterIdentityChange doc for the rationale.
|
||||
onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange
|
||||
})
|
||||
|
||||
// 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') {
|
||||
const normalizedJid = jidNormalizedUser(from)
|
||||
resolveTcTokenJid(normalizedJid, getLIDForPN)
|
||||
.then(async tcJid => {
|
||||
const tcData = await authState.keys.get('tctoken', [tcJid])
|
||||
const entry = tcData[tcJid]
|
||||
if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
|
||||
const senderTs = unixTimestampSeconds()
|
||||
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
|
||||
getPrivacyTokens([normalizedJid], senderTs)
|
||||
.then(async (iqResult) => {
|
||||
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(() => {
|
||||
/* ignore resolution errors */
|
||||
})
|
||||
}
|
||||
|
||||
if (result.action === 'no_identity_node') {
|
||||
logger.info({ node }, 'unknown encrypt notification')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user