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:
Renato Alcara
2026-04-25 18:52:45 -03:00
committed by GitHub
parent 9525f42cb2
commit 9ff21db749
8 changed files with 828 additions and 71 deletions
+93 -60
View File
@@ -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 */