feat(tctoken): complete lifecycle (TIER 1 + 2 + 3 of upstream PR)
feat(tctoken): complete lifecycle (TIER 1 + 2 + 3 of upstream PR)
This commit is contained in:
+42
-2
@@ -98,6 +98,24 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
|
||||
let privacySettings: { [_: string]: string } | undefined
|
||||
|
||||
/**
|
||||
* Server-assigned AB props that gate tctoken-related protocol behavior.
|
||||
* Defaults match WA Web (safe — avoids spurious error 463 if the prop never
|
||||
* arrives). Populated from `fetchProps()` on connection.
|
||||
*
|
||||
* - `privacyTokenOn1to1` (AB prop 10518 / `privacy_token_sending_on_all_1_on_1_messages`):
|
||||
* include tctoken in 1:1 messages.
|
||||
* - `profilePicPrivacyToken` (AB prop 9666 / `profile_scraping_privacy_token_in_photo_iq`):
|
||||
* include tctoken in profile picture IQs.
|
||||
* - `lidTrustedTokenIssueToLid` (AB prop 14303 / `lid_trusted_token_issue_to_lid`):
|
||||
* issue privacy tokens to the contact's LID instead of the PN.
|
||||
*/
|
||||
const serverProps = {
|
||||
privacyTokenOn1to1: true,
|
||||
profilePicPrivacyToken: true,
|
||||
lidTrustedTokenIssueToLid: false
|
||||
}
|
||||
|
||||
let syncState: SyncState = SyncState.Connecting
|
||||
|
||||
/** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */
|
||||
@@ -791,7 +809,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
|
||||
let content: BinaryNode[] | undefined = baseContent
|
||||
|
||||
if (isUserJid && !isSelf) {
|
||||
// Gate inclusion on AB prop 9666 (profile_scraping_privacy_token_in_photo_iq).
|
||||
// WA Web defaults to true; if the server flips it off, we mirror that to
|
||||
// avoid divergence with the spec-compliant client.
|
||||
if (serverProps.profilePicPrivacyToken && isUserJid && !isSelf) {
|
||||
content = await buildTcTokenFromJid({
|
||||
authState,
|
||||
jid: normalizedJid,
|
||||
@@ -1093,7 +1114,25 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
|
||||
}
|
||||
|
||||
logger.debug('fetched props')
|
||||
// Extract protocol-relevant AB props (defaults match WA Web; see serverProps doc).
|
||||
// We accept both numeric IDs and human-readable names so the parser is resilient
|
||||
// to upstream renaming and to future-versioned WA Web servers.
|
||||
const privacyTokenProp = props['10518'] ?? props['privacy_token_sending_on_all_1_on_1_messages']
|
||||
if (privacyTokenProp !== undefined) {
|
||||
serverProps.privacyTokenOn1to1 = privacyTokenProp === 'true' || privacyTokenProp === '1'
|
||||
}
|
||||
|
||||
const profilePicProp = props['9666'] ?? props['profile_scraping_privacy_token_in_photo_iq']
|
||||
if (profilePicProp !== undefined) {
|
||||
serverProps.profilePicPrivacyToken = profilePicProp === 'true' || profilePicProp === '1'
|
||||
}
|
||||
|
||||
const lidIssueProp = props['14303'] ?? props['lid_trusted_token_issue_to_lid']
|
||||
if (lidIssueProp !== undefined) {
|
||||
serverProps.lidTrustedTokenIssueToLid = lidIssueProp === 'true' || lidIssueProp === '1'
|
||||
}
|
||||
|
||||
logger.debug({ serverProps }, 'fetched props')
|
||||
|
||||
return props
|
||||
}
|
||||
@@ -1611,6 +1650,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
|
||||
return {
|
||||
...sock,
|
||||
serverProps,
|
||||
createCallLink,
|
||||
getBotListV2,
|
||||
messageMutex,
|
||||
|
||||
+93
-60
@@ -73,7 +73,14 @@ import {
|
||||
recordMessageReceived,
|
||||
recordMessageRetry
|
||||
} from '../Utils/prometheus-metrics.js'
|
||||
import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils'
|
||||
import {
|
||||
buildMergedTcTokenIndexWrite,
|
||||
isTcTokenExpired,
|
||||
resolveIssuanceJid,
|
||||
resolveTcTokenJid,
|
||||
storeTcTokensFromIqResult,
|
||||
TC_TOKEN_INDEX_KEY
|
||||
} from '../Utils/tc-token-utils'
|
||||
import {
|
||||
areJidsSameUser,
|
||||
type BinaryNode,
|
||||
@@ -164,7 +171,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>()
|
||||
@@ -198,16 +210,25 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
}
|
||||
})()
|
||||
|
||||
/** Debounced save of the tctoken JID index (5s) */
|
||||
/**
|
||||
* Debounced save of the tctoken JID index (5s).
|
||||
*
|
||||
* Merges with the persisted index instead of overwriting — other layers
|
||||
* (messages-send fire-and-forget issuance, process-message history sync) may
|
||||
* write JIDs to the index via `buildMergedTcTokenIndexWrite` without updating
|
||||
* `tcTokenKnownJids`. Without the merge those JIDs would be silently dropped
|
||||
* the next time this debounced save fires.
|
||||
*/
|
||||
const scheduleTcTokenIndexSave = () => {
|
||||
if (tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
|
||||
tcTokenIndexSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const arr = Array.from(tcTokenKnownJids)
|
||||
const merged = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
|
||||
await authState.keys.set({
|
||||
tctoken: {
|
||||
...merged,
|
||||
[TC_TOKEN_INDEX_KEY]: {
|
||||
token: Buffer.from(JSON.stringify(arr), 'utf8'),
|
||||
...merged[TC_TOKEN_INDEX_KEY],
|
||||
timestamp: unixTimestampSeconds().toString()
|
||||
}
|
||||
}
|
||||
@@ -1434,6 +1455,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) races 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 +1526,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')
|
||||
}
|
||||
@@ -3220,19 +3248,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
// Await index load first — prevents overwriting a more complete persisted index
|
||||
// if the connection closes before the initial load finishes.
|
||||
tcTokenIndexLoaded
|
||||
.then(() => {
|
||||
Promise.resolve(
|
||||
authState.keys.set({
|
||||
.then(async () => {
|
||||
try {
|
||||
// Same merge-with-persisted invariant as scheduleTcTokenIndexSave —
|
||||
// other layers may have written cross-layer JIDs to the index since
|
||||
// our in-memory set was last updated.
|
||||
const merged = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
|
||||
await authState.keys.set({
|
||||
tctoken: {
|
||||
...merged,
|
||||
[TC_TOKEN_INDEX_KEY]: {
|
||||
token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'),
|
||||
...merged[TC_TOKEN_INDEX_KEY],
|
||||
timestamp: unixTimestampSeconds().toString()
|
||||
}
|
||||
}
|
||||
})
|
||||
).catch(() => {
|
||||
} catch {
|
||||
/* non-critical */
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* non-critical */
|
||||
|
||||
@@ -46,7 +46,10 @@ import { makeKeyedMutex } from '../Utils/make-mutex'
|
||||
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
|
||||
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
|
||||
import {
|
||||
buildMergedTcTokenIndexWrite,
|
||||
isRegularUser,
|
||||
isTcTokenExpired,
|
||||
resolveIssuanceJid,
|
||||
resolveTcTokenJid,
|
||||
shouldSendNewTcToken,
|
||||
storeTcTokensFromIqResult
|
||||
@@ -125,6 +128,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
// Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches
|
||||
const tcTokenFetchingJids = new Set<string>()
|
||||
|
||||
/**
|
||||
* Set of tctoken storage JIDs with a fire-and-forget `issuePrivacyTokens` IQ in flight.
|
||||
* Distinct from `tcTokenFetchingJids` (which dedupes inbound *fetches* of the peer's
|
||||
* token). Prevents duplicate IQs when a caller fires several rapid back-to-back sends
|
||||
* to the same contact before `senderTimestamp` persists. Entries are always removed
|
||||
* in `.finally()`, so the set is bounded by current concurrency.
|
||||
*/
|
||||
const inFlightTcTokenIssuance = new Set<string>()
|
||||
|
||||
let mediaConn: Promise<MediaConnInfo>
|
||||
const refreshMediaConn = async (forceGet = false) => {
|
||||
const media = await mediaConn
|
||||
@@ -1715,7 +1727,16 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (tcTokenBuffer?.length) {
|
||||
// Gate inclusion on AB prop 10518 (privacy_token_sending_on_all_1_on_1_messages).
|
||||
// WA Web defaults to true; if the server flips it off we mirror that to avoid
|
||||
// divergence with the spec-compliant client.
|
||||
//
|
||||
// CARROUSEL EXCEPTION: Pastorini-validated carousel stanzas REQUIRE tctoken
|
||||
// (CDP capture confirms it). If the AB prop ever flips off, dropping the
|
||||
// tctoken from carousel would break rendering on Android. Carousel always
|
||||
// includes the tctoken when one is available, regardless of the prop —
|
||||
// matching the fork's existing behaviour pre-PR #2339.
|
||||
if (tcTokenBuffer?.length && (sock.serverProps.privacyTokenOn1to1 || isCarousel)) {
|
||||
;(stanza.content as BinaryNode[]).push({
|
||||
tag: 'tctoken',
|
||||
attrs: {},
|
||||
@@ -1815,13 +1836,39 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
// Gated only by shouldSendNewTcToken — removed tcTokenBuffer?.length guard so
|
||||
// issuance fires even when we don't yet hold a token (bucket boundary crossed).
|
||||
// IMPORTANT: must run AFTER sendNode — issuing before the message causes error 463.
|
||||
if (is1on1Send && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) {
|
||||
//
|
||||
// WA Web also skips issuance for:
|
||||
// - protocol messages (revoke, ephemeral settings, etc — see TcTokenChatAction)
|
||||
// - PSA, bots, MetaAI (isRegularUser filter)
|
||||
// and dedupes back-to-back issuances with `inFlightTcTokenIssuance` so a burst of
|
||||
// rapid sends to the same contact only triggers a single IQ before senderTimestamp
|
||||
// is persisted.
|
||||
const isProtocolMsg = !!normalizeMessageContent(message)?.protocolMessage
|
||||
// Use isRegularUser (the same Wid.isRegularUser() port that gates the store
|
||||
// path) so we filter PSA/bot/MetaAI consistently regardless of JID server
|
||||
// (@c.us vs @s.whatsapp.net) and device suffix. The previous PSA_WID/isJidBot
|
||||
// checks only matched @c.us forms — destinationJid arrives normalized.
|
||||
const isBotOrPSA = !isRegularUser(destinationJid)
|
||||
if (
|
||||
is1on1Send &&
|
||||
!isProtocolMsg &&
|
||||
!isBotOrPSA &&
|
||||
shouldSendNewTcToken(existingTokenEntry?.senderTimestamp) &&
|
||||
!inFlightTcTokenIssuance.has(tcTokenJid)
|
||||
) {
|
||||
inFlightTcTokenIssuance.add(tcTokenJid)
|
||||
const issueTimestamp = unixTimestampSeconds()
|
||||
logTcToken('reissue', { jid: destinationJid })
|
||||
getPrivacyTokens([destinationJid], issueTimestamp)
|
||||
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
|
||||
resolveIssuanceJid(
|
||||
destinationJid,
|
||||
sock.serverProps.lidTrustedTokenIssueToLid,
|
||||
getLIDForPN,
|
||||
getPNForLID
|
||||
)
|
||||
.then(issueJid => getPrivacyTokens([issueJid], issueTimestamp))
|
||||
.then(async result => {
|
||||
// Store any tokens received in the IQ response.
|
||||
// onNewJidStored not passed — pruning index lives in messages-recv (higher layer).
|
||||
await storeTcTokensFromIqResult({
|
||||
result,
|
||||
fallbackJid: tcTokenJid,
|
||||
@@ -1832,9 +1879,11 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
// Persist senderTimestamp unconditionally — WA Web stores it in the chat table
|
||||
// regardless of whether a token exists. Spread preserves token+timestamp if present.
|
||||
// WABA Android: INSERT INTO wa_trusted_contacts_send (jid, sent_tc_token_timestamp, real_issue_timestamp)
|
||||
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server
|
||||
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server.
|
||||
// Also bump the cross-session prune index so this JID is tracked persistently.
|
||||
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
|
||||
const currentEntry = currentData[tcTokenJid]
|
||||
const indexWrite = await buildMergedTcTokenIndexWrite(authState.keys, [tcTokenJid])
|
||||
await authState.keys.set({
|
||||
tctoken: {
|
||||
[tcTokenJid]: {
|
||||
@@ -1842,7 +1891,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
token: currentEntry?.token ?? Buffer.alloc(0),
|
||||
senderTimestamp: issueTimestamp,
|
||||
realIssueTimestamp: 0
|
||||
}
|
||||
},
|
||||
...indexWrite
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1851,6 +1901,9 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
.catch(err => {
|
||||
logTcToken('reissue_fail', { jid: destinationJid, error: err?.message })
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightTcTokenIssuance.delete(tcTokenJid)
|
||||
})
|
||||
}
|
||||
|
||||
// Log with [BAILEYS] prefix
|
||||
|
||||
Reference in New Issue
Block a user