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
+109 -2
View File
@@ -1,12 +1,84 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary'
import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidNormalizedUser } from '../WABinary'
import {
getBinaryNodeChild,
getBinaryNodeChildren,
isHostedLidUser,
isHostedPnUser,
isJidMetaAI,
isLidUser,
isPnUser,
jidNormalizedUser
} from '../WABinary'
/** 7 days in seconds — matches WA Web AB prop tctoken_duration */
const TC_TOKEN_BUCKET_DURATION = 604800
/** 4 buckets → ~28-day rolling window — matches WA Web AB prop tctoken_num_buckets */
const TC_TOKEN_NUM_BUCKETS = 4
/**
* Sentinel key under the `tctoken` store holding a JSON array of tracked storage JIDs
* for cross-session pruning. Mirrors WA Web's CLEAN_TC_TOKENS index lookup.
*
* Exported so other modules (messages-recv, messages-send, process-message) reference
* the same constant. The fork previously inlined this string in messages-recv.ts;
* keep the value identical (`'__index'`) for backward compatibility with persisted state.
*/
export const TC_TOKEN_INDEX_KEY = '__index'
// Phone-number pattern matching WABinary's isJidBot, applied against the user part so
// the check is invariant to @c.us ↔ @s.whatsapp.net normalization.
const BOT_PHONE_REGEX = /^1313555\d{4}$|^131655500\d{2}$/
/**
* Mirrors WA Web's `Wid.isRegularUser()` (user ∧ ¬PSA ∧ ¬Bot). Used to gate tctoken
* storage against malformed notifications — WA Web filters server-side but we
* defend here for parity with `WAWebSetTcTokenChatAction.handleIncomingTcToken`.
* Works for both pre- and post-normalized JIDs (`@c.us` vs `@s.whatsapp.net`).
*/
export function isRegularUser(jid: string | undefined): boolean {
if (!jid) return false
const user = jid.split('@')[0] ?? ''
if (user === '0') return false // PSA
if (BOT_PHONE_REGEX.test(user)) return false // Bot by phone pattern
if (isJidMetaAI(jid)) return false // MetaAI (@bot server)
return !!(isPnUser(jid) || isLidUser(jid) || isHostedPnUser(jid) || isHostedLidUser(jid) || jid.endsWith('@c.us'))
}
/** Read the persisted tctoken JID index and return its entries (never contains the sentinel key itself). */
export async function readTcTokenIndex(keys: SignalKeyStoreWithTransaction): Promise<string[]> {
const data = await keys.get('tctoken', [TC_TOKEN_INDEX_KEY])
const entry = data[TC_TOKEN_INDEX_KEY]
if (!entry?.token?.length) return []
try {
const parsed = JSON.parse(Buffer.from(entry.token).toString())
if (!Array.isArray(parsed)) return []
return parsed.filter((j): j is string => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY)
} catch {
return []
}
}
/**
* Build a SignalDataSet fragment that writes the merged index (persisted added)
* under the sentinel key. Lets callers update the index without clobbering writes
* made by other layers (history sync, concurrent sessions on the same store).
*/
export async function buildMergedTcTokenIndexWrite(
keys: SignalKeyStoreWithTransaction,
addedJids: Iterable<string>
): Promise<{ [TC_TOKEN_INDEX_KEY]: { token: Buffer } }> {
const persisted = await readTcTokenIndex(keys)
const merged = new Set(persisted)
for (const jid of addedJids) {
if (jid && jid !== TC_TOKEN_INDEX_KEY) merged.add(jid)
}
return {
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...merged])) }
}
}
/**
* Check if a received token is expired using WA Web's rolling bucket algorithm.
* Reference: WAWebTrustedContactsUtils.isTokenExpired
@@ -63,6 +135,35 @@ export async function resolveTcTokenJid(
return lid ?? normalized
}
/**
* Resolve target JID for issuing privacy token based on AB prop 14303
* (`lid_trusted_token_issue_to_lid`). When the prop is on, issuance goes to
* the LID; when off, it goes to the PN. Returns the original JID if no
* mapping is found in either direction.
*
* Reference: WAWebTrustedContactsManager.issuePrivacyTokens
*/
export async function resolveIssuanceJid(
jid: string,
issueToLid: boolean,
getLIDForPN: (pn: string) => Promise<string | null>,
getPNForLID?: (lid: string) => Promise<string | null>
): Promise<string> {
if (issueToLid) {
if (isLidUser(jid)) return jid
const lid = await getLIDForPN(jid)
return lid ?? jid
}
if (!isLidUser(jid)) return jid
if (getPNForLID) {
const pn = await getPNForLID(jid)
return pn ?? jid
}
return jid
}
type TcTokenParams = {
jid: string
baseContent?: BinaryNode[]
@@ -135,7 +236,13 @@ export async function storeTcTokensFromIqResult({
continue
}
const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid)
// In notifications, tokenNode.attrs.jid is OUR own device JID, not the sender's.
// Prefer fallbackJid (resolved from notification's `from` / `sender_lid`) so the
// token is stored under the peer's JID, never under self.
const rawJid = jidNormalizedUser(fallbackJid || tokenNode.attrs.jid)
// Defense against malformed notifications (PSA WID '0', bots, MetaAI). WA Web
// filters these server-side; we mirror Wid.isRegularUser() locally.
if (!isRegularUser(rawJid)) continue
const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN)
const existingTcData = await keys.get('tctoken', [storageJid])
const existingEntry = existingTcData[storageJid]