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:
+109
-2
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user