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:
Renato Alcara
2026-04-25 18:00:05 -03:00
parent 9525f42cb2
commit 225b692b7c
8 changed files with 738 additions and 62 deletions
+85
View File
@@ -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) {