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:
@@ -40,6 +40,7 @@ import { getKeyAuthor, toNumber } from './generics'
|
||||
import { downloadAndProcessHistorySyncNotification } from './history'
|
||||
import type { ILogger } from './logger'
|
||||
import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js'
|
||||
import { buildMergedTcTokenIndexWrite, resolveTcTokenJid } from './tc-token-utils'
|
||||
|
||||
type ProcessMessageContext = {
|
||||
shouldProcessHistoryMsg: boolean
|
||||
@@ -62,6 +63,85 @@ const REAL_MSG_STUB_TYPES = new Set([
|
||||
|
||||
const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD])
|
||||
|
||||
/**
|
||||
* Extract tctoken / tcTokenTimestamp / tcTokenSenderTimestamp from history-sync chats
|
||||
* and persist them to the `tctoken` store. Mirrors WA Web's `bulkCreateOrMerge` pass
|
||||
* over the chat table during history sync.
|
||||
*
|
||||
* Why this matters: when a user logs in on a new device, the multi-device history sync
|
||||
* is the only way that device learns about tctokens issued/received on the original
|
||||
* device. Without this pass, the new device sends 1:1 messages with no tctoken until
|
||||
* the contact triggers a fresh notification — which surfaces as error 463 in production.
|
||||
*
|
||||
* Monotonicity: we only overwrite an existing entry if the incoming timestamp is
|
||||
* STRICTLY newer (`incoming > existing`). Equal timestamps are skipped to avoid
|
||||
* reverting senderTimestamp / realIssueTimestamp set by other layers (e.g. a
|
||||
* reissue that fired between history-sync chunks).
|
||||
*
|
||||
* Index hygiene: every JID we write here is added to the persistent prune index
|
||||
* (TC_TOKEN_INDEX_KEY) via buildMergedTcTokenIndexWrite, so the 24h prune sweep in
|
||||
* messages-recv picks them up across sessions.
|
||||
*/
|
||||
async function storeTcTokensFromHistorySync(
|
||||
chats: Chat[],
|
||||
signalRepository: SignalRepositoryWithLIDStore,
|
||||
keyStore: SignalKeyStoreWithTransaction,
|
||||
logger?: ILogger
|
||||
) {
|
||||
const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
|
||||
|
||||
const candidates: { storageJid: string; token: Buffer; ts: number; senderTs?: number }[] = []
|
||||
for (const chat of chats) {
|
||||
const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0
|
||||
if (chat.tcToken?.length && ts > 0) {
|
||||
const jid = jidNormalizedUser(chat.id!)
|
||||
const storageJid = await resolveTcTokenJid(jid, getLIDForPN)
|
||||
candidates.push({
|
||||
storageJid,
|
||||
token: Buffer.from(chat.tcToken),
|
||||
ts,
|
||||
senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidates.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const jids = candidates.map(c => c.storageJid)
|
||||
const existing = await keyStore.get('tctoken', jids)
|
||||
const entries: Record<string, { token: Buffer; timestamp?: string; senderTimestamp?: number }> = {}
|
||||
|
||||
for (const c of candidates) {
|
||||
const existingEntry = existing[c.storageJid]
|
||||
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
|
||||
// Strict > guard: equal timestamps are skipped so we never clobber
|
||||
// senderTimestamp written by other layers (issuance after send, etc).
|
||||
if (existingTs > 0 && existingTs >= c.ts) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries[c.storageJid] = {
|
||||
...existingEntry,
|
||||
token: c.token,
|
||||
timestamp: String(c.ts),
|
||||
...(c.senderTs !== undefined ? { senderTimestamp: c.senderTs } : {})
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length) {
|
||||
logger?.debug({ count: Object.keys(entries).length }, 'storing tctokens from history sync')
|
||||
try {
|
||||
// Include updated __index so cross-session pruning picks these JIDs up.
|
||||
const indexWrite = await buildMergedTcTokenIndexWrite(keyStore, Object.keys(entries))
|
||||
await keyStore.set({ tctoken: { ...entries, ...indexWrite } })
|
||||
} catch (err) {
|
||||
logger?.warn({ err }, 'failed to store tctokens from history sync')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleans a received message to further processing */
|
||||
export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => {
|
||||
// ensure remoteJid and participant doesn't have device or agent in it
|
||||
@@ -416,6 +496,11 @@ const processMessage = async (
|
||||
|
||||
const data = await downloadAndProcessHistorySyncNotification(histNotification, options, logger)
|
||||
|
||||
// Persist tctokens carried by history-sync chats BEFORE emitting messaging-history.set
|
||||
// — listeners may immediately fire outbound sends that need the tctoken, and the store
|
||||
// has to be populated first to avoid an error 463 on the first multi-device send.
|
||||
await storeTcTokensFromHistorySync(data.chats, signalRepository, keyStore, logger)
|
||||
|
||||
// Emit LID-PN mappings from history sync
|
||||
// This is how WhatsApp Web learns mappings for chats with non-contacts
|
||||
if (data.lidPnMappings?.length) {
|
||||
|
||||
Reference in New Issue
Block a user