feat: integrate tctoken lifecycle with expiration, pruning and re-issuance (PR #2339)

Surgical integration of WhiskeySockets/Baileys PR #2339 preserving all
InfiniteAPI customizations (biz nodes, DSM skip, carousel, Prometheus,
circuit breaker, LID mapping, CTWA recovery, identity debounce).

Changes:
- tc-token-utils.ts: rolling bucket expiration (28d/4 buckets), LID
  resolution, monotonicity guard, storeTcTokensFromIqResult parser
- messages-send.ts: proactive fetch if missing/expired, fire-and-forget
  re-issuance on bucket boundary, getPrivacyTokens timestamp param
- messages-recv.ts: persistent JID index for cross-session pruning,
  pruneExpiredTcTokens on connect (max 1x/24h), session_refreshed
  re-issuance, error 463/479 handling in handleBadAck
- chats.ts: self-detection in profilePictureUrl, LID resolver in
  presenceSubscribe
- socket.ts: granular stream error logging (device_removed, xml, ack)
- Auth.ts: senderTimestamp field on tctoken type
- Types/index.ts: sessionInvalidated = 516 disconnect reason
- decode-wa-message.ts: SERVER_ERROR_CODES constant (463, 479, 421, 475)
- generics.ts: enhanced getErrorCodeFromStreamError with device_removed
- baileys-logger.ts: logTcToken function following [BAILEYS] prefix pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Renato Alcara
2026-02-15 20:53:27 -03:00
parent 938c6aaa49
commit 08793904e2
10 changed files with 506 additions and 55 deletions
+27 -4
View File
@@ -50,6 +50,8 @@ import {
type BinaryNode,
getBinaryNodeChild,
getBinaryNodeChildren,
isLidUser,
isPnUser,
jidDecode,
jidNormalizedUser,
reduceBinaryNodeToDictionary,
@@ -695,9 +697,26 @@ export const makeChatsSocket = (config: SocketConfig) => {
const profilePictureUrl = async (jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
const baseContent: BinaryNode[] = [{ tag: 'picture', attrs: { type, query: 'url' } }]
const tcTokenContent = await buildTcTokenFromJid({ authState, jid, baseContent })
// WA Web only includes tctoken for user JIDs (not groups/newsletters)
// and never for own profile pic (Chat model for self has no tcToken).
// Including tctoken for own JID causes the server to never respond.
const normalizedJid = jidNormalizedUser(jid)
const isUserJid = isPnUser(normalizedJid) || isLidUser(normalizedJid)
const me = authState.creds.me
const isSelf =
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
let content: BinaryNode[] | undefined = baseContent
jid = jidNormalizedUser(jid)
if(isUserJid && !isSelf) {
content = await buildTcTokenFromJid({
authState,
jid: normalizedJid,
baseContent,
getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
})
}
jid = normalizedJid
const result = await query(
{
tag: 'iq',
@@ -707,7 +726,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
type: 'get',
xmlns: 'w:profile:picture'
},
content: tcTokenContent
content
},
timeoutMs
)
@@ -799,7 +818,11 @@ export const makeChatsSocket = (config: SocketConfig) => {
* @param tcToken token for subscription, use if present
*/
const presenceSubscribe = async (toJid: string) => {
const tcTokenContent = await buildTcTokenFromJid({ authState, jid: toJid })
const tcTokenContent = await buildTcTokenFromJid({
authState,
jid: toJid,
getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
})
return sendNode({
tag: 'presence',
+148 -32
View File
@@ -47,13 +47,14 @@ import {
MISSING_KEYS_ERROR_TEXT,
NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT,
SERVER_ERROR_CODES,
normalizeMessageJids,
toNumber,
unixTimestampSeconds,
xmppPreKey,
xmppSignedPreKey
} from '../Utils'
import { logMessageReceived } from '../Utils/baileys-logger'
import { logMessageReceived, logTcToken } from '../Utils/baileys-logger'
import { makeMutex } from '../Utils/make-mutex'
import {
metrics,
@@ -80,6 +81,7 @@ import {
jidNormalizedUser,
S_WHATSAPP_NET
} from '../WABinary'
import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
import { extractGroupMetadata } from './groups'
import { makeMessagesSocket } from './messages-send'
@@ -117,7 +119,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
sendReceipt,
uploadPreKeys,
sendPeerDataOperationMessage,
messageRetryManager
messageRetryManager,
getPrivacyTokens
} = sock
/** this mutex ensures that each retryRequest will wait for the previous one to finish */
@@ -148,6 +151,78 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let sendActiveReceipts = false
// ======= tctoken index tracking for cross-session pruning =======
const TC_TOKEN_INDEX_KEY = '__index'
const tcTokenKnownJids = new Set<string>()
let tcTokenIndexSaveTimer: ReturnType<typeof setTimeout> | undefined
let lastTcTokenPruneTs = 0
// Load persisted JID index on startup
const tcTokenIndexLoaded = (async () => {
try {
const data = await authState.keys.get('tctoken', [TC_TOKEN_INDEX_KEY])
const entry = data[TC_TOKEN_INDEX_KEY]
if(entry?.token) {
const stored = JSON.parse(Buffer.from(entry.token).toString('utf8'))
if(Array.isArray(stored)) {
for(const jid of stored) tcTokenKnownJids.add(jid)
}
}
} catch { /* first run or corrupt index — start fresh */ }
})()
/** Debounced save of the tctoken JID index (5s) */
const scheduleTcTokenIndexSave = () => {
if(tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
tcTokenIndexSaveTimer = setTimeout(async () => {
try {
const arr = Array.from(tcTokenKnownJids)
await authState.keys.set({
tctoken: {
[TC_TOKEN_INDEX_KEY]: {
token: Buffer.from(JSON.stringify(arr), 'utf8'),
timestamp: unixTimestampSeconds().toString()
}
}
})
} catch(err) {
logger.debug({ err }, 'failed to persist tctoken index')
}
}, 5000)
}
/** Delete expired tctokens — runs at most once per 24h when coming online */
const pruneExpiredTcTokens = async () => {
await tcTokenIndexLoaded
const pruneSet: Record<string, null> = {}
const survivingJids: string[] = []
for(const jid of tcTokenKnownJids) {
if(jid === TC_TOKEN_INDEX_KEY) continue
try {
const data = await authState.keys.get('tctoken', [jid])
const entry = data[jid]
if(!entry?.token || isTcTokenExpired(entry.timestamp)) {
pruneSet[jid] = null
} else {
survivingJids.push(jid)
}
} catch {
pruneSet[jid] = null
}
}
const pruneCount = Object.keys(pruneSet).length
if(pruneCount > 0) {
await authState.keys.set({ tctoken: pruneSet })
tcTokenKnownJids.clear()
for(const jid of survivingJids) tcTokenKnownJids.add(jid)
scheduleTcTokenIndexSave()
logTcToken('prune', { pruned: pruneCount, remaining: survivingJids.length })
}
}
// ======= END tctoken index tracking =======
const fetchMessageHistory = async (
count: number,
oldestMsgKey: WAMessageKey,
@@ -633,7 +708,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger
})
if (result.action === 'no_identity_node') {
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget
if(result.action === 'session_refreshed') {
const normalizedJid = jidNormalizedUser(from)
resolveTcTokenJid(
normalizedJid,
signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
).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).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')
}
}
@@ -958,28 +1052,45 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const tokensNode = getBinaryNodeChild(node, 'tokens')
const from = jidNormalizedUser(node.attrs.from)
if (!tokensNode) return
if(!tokensNode) return
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
for (const tokenNode of tokenNodes) {
for(const tokenNode of tokenNodes) {
const { attrs, content } = tokenNode
const type = attrs.type
const timestamp = attrs.t
if (type === 'trusted_contact' && content instanceof Buffer) {
logger.debug(
{
from,
timestamp,
tcToken: content
},
'received trusted contact token'
)
if(type === 'trusted_contact' && content instanceof Uint8Array) {
// Resolve to LID for consistent storage key
const senderLid = attrs.sender_lid ? jidNormalizedUser(attrs.sender_lid) : undefined
const storageJid = senderLid || await resolveTcTokenJid(from, getLIDForPN)
// Timestamp monotonicity guard — only store if incoming >= existing
const existingData = await authState.keys.get('tctoken', [storageJid])
const existing = existingData[storageJid]
const existingTs = existing?.timestamp ? Number(existing.timestamp) : 0
const incomingTs = timestamp ? Number(timestamp) : 0
if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
continue
}
await authState.keys.set({
tctoken: { [from]: { token: content, timestamp } }
tctoken: {
[storageJid]: {
...existing,
token: Buffer.from(content),
timestamp
}
}
})
logTcToken('stored', { jid: storageJid, from })
// Track JID for cross-session pruning
tcTokenKnownJids.add(storageJid)
scheduleTcTokenIndexSave()
}
}
}
@@ -1755,8 +1866,15 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// error in acknowledgement,
// device could not display the message
if (attrs.error) {
logger.warn({ attrs }, 'received error in ack')
if(attrs.error) {
if(attrs.error === SERVER_ERROR_CODES.MissingTcToken) {
logTcToken('error_463', { jid: attrs.from, msgId: attrs.id })
} else if(attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
} else {
logger.warn({ attrs }, 'received error in ack')
}
ev.emit('messages.update', [
{
key,
@@ -1766,20 +1884,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
}
])
// resend the message with device_fanout=false, use at your own risk
// if (attrs.error === '475') {
// const msg = await getMessage(key)
// if (msg) {
// await relayMessage(key.remoteJid!, msg, {
// messageId: key.id!,
// useUserDevicesCache: false,
// additionalAttributes: {
// device_fanout: 'false'
// }
// })
// }
// }
}
}
@@ -1936,9 +2040,21 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
})
ev.on('connection.update', ({ isOnline }) => {
if (typeof isOnline !== 'undefined') {
if(typeof isOnline !== 'undefined') {
sendActiveReceipts = isOnline
logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`)
// Prune expired tctokens when coming online (max once per 24h)
if(isOnline) {
const now = Date.now()
const ONE_DAY_MS = 86400000
if(now - lastTcTokenPruneTs > ONE_DAY_MS) {
lastTcTokenPruneTs = now
pruneExpiredTcTokens().catch(err => {
logger.debug({ err: err?.message }, 'tctoken pruning failed')
})
}
}
}
})
+107 -8
View File
@@ -39,11 +39,17 @@ import {
parseAndInjectE2ESessions,
unixTimestampSeconds
} from '../Utils'
import { logMessageSent } from '../Utils/baileys-logger'
import { logMessageSent, logTcToken } from '../Utils/baileys-logger'
import { getUrlInfo } from '../Utils/link-preview'
import { makeKeyedMutex } from '../Utils/make-mutex'
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
import {
isTcTokenExpired,
resolveTcTokenJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult
} from '../Utils/tc-token-utils'
import {
areJidsSameUser,
type BinaryNode,
@@ -1475,18 +1481,79 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// Working carousel includes tctoken in stanza
const contactTcTokenData =
!isGroup && !isRetryResend && !isStatus ? await authState.keys.get('tctoken', [destinationJid]) : {}
// tctoken lifecycle: fetch, validate expiry, proactive re-fetch if missing/expired
const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter
let didFetchTcToken = false
const tcTokenBuffer = contactTcTokenData[destinationJid]?.token
// Resolve destination to LID for tctoken storage — matches Signal session key pattern
const tcTokenJid = is1on1Send
? await resolveTcTokenJid(
destinationJid,
signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
)
: destinationJid
const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {}
const existingTokenEntry = contactTcTokenData[tcTokenJid]
let tcTokenBuffer = existingTokenEntry?.token
if (tcTokenBuffer) {
// Treat expired tokens the same as missing — re-fetch from server
if(tcTokenBuffer?.length && isTcTokenExpired(existingTokenEntry?.timestamp)) {
logTcToken('expired', { jid: destinationJid, timestamp: existingTokenEntry?.timestamp })
tcTokenBuffer = undefined
// Opportunistic cleanup: remove expired token from store
try {
await authState.keys.set({ tctoken: { [tcTokenJid]: null } })
} catch { /* ignore cleanup errors */ }
}
// If tctoken is missing or expired for a 1:1 send, proactively fetch it from the server
if(!tcTokenBuffer?.length && is1on1Send) {
try {
logTcToken('fetch', { jid: destinationJid })
didFetchTcToken = true
const fetchResult = await getPrivacyTokens([destinationJid])
// Parse inline tokens from IQ result using the shared parser
// (includes monotonicity guard)
await storeTcTokensFromIqResult({
result: fetchResult,
fallbackJid: destinationJid,
keys: authState.keys,
getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
})
// Re-read from key store — the notification handler or inline
// parsing above may have stored the token
const refreshed = await authState.keys.get('tctoken', [tcTokenJid])
const refreshedEntry = refreshed[tcTokenJid]
tcTokenBuffer = refreshedEntry?.token
// The getPrivacyTokens IQ (type='set') also acts as issuance,
// so record senderTimestamp to prevent redundant fire-and-forget
// on the next message to this contact.
if(refreshedEntry?.token?.length) {
logTcToken('fetched', { jid: destinationJid })
await authState.keys.set({
tctoken: {
[tcTokenJid]: {
...refreshedEntry,
senderTimestamp: unixTimestampSeconds()
}
}
})
}
} catch(err: any) {
logger.warn({ jid: destinationJid, trace: err?.stack }, 'failed to fetch privacy token before send')
}
}
if(tcTokenBuffer?.length) {
;(stanza.content as BinaryNode[]).push({
tag: 'tctoken',
attrs: {},
content: tcTokenBuffer
})
logTcToken('attached', { jid: destinationJid })
}
if (additionalNodes && additionalNodes.length > 0) {
@@ -1568,6 +1635,38 @@ export const makeMessagesSocket = (config: SocketConfig) => {
await sendNode(stanza)
// Fire-and-forget: issue our token to the contact (like WA Web's sendTcToken)
// Only for 1:1 sends where we didn't already fetch, and only when bucket boundary crossed
if(is1on1Send && !didFetchTcToken && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) {
const issueTimestamp = unixTimestampSeconds()
logTcToken('reissue', { jid: destinationJid })
// WA Web writes senderTimestamp only AFTER the IQ succeeds
// (WAWebSendTcTokenChatAction.sendTcToken).
// This ensures failed issuance allows re-issuance on the next message
// rather than blocking it for up to 7 days (one bucket duration).
getPrivacyTokens([destinationJid], issueTimestamp)
.then(async () => {
// Re-read entry to avoid overwriting concurrent notification handler updates
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid]
if(currentEntry?.token?.length) {
await authState.keys.set({
tctoken: {
[tcTokenJid]: {
...currentEntry,
senderTimestamp: issueTimestamp
}
}
})
}
logTcToken('reissue_ok', { jid: destinationJid })
})
.catch(err => {
logTcToken('reissue_fail', { jid: destinationJid, error: err?.message })
})
}
// Log with [BAILEYS] prefix
logMessageSent(msgId, destinationJid)
@@ -1677,8 +1776,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return ''
}
const getPrivacyTokens = async (jids: string[]) => {
const t = unixTimestampSeconds().toString()
const getPrivacyTokens = async (jids: string[], timestamp?: number) => {
const t = (timestamp ?? unixTimestampSeconds()).toString()
const result = await query({
tag: 'iq',
attrs: {
+10 -2
View File
@@ -1529,10 +1529,18 @@ export const makeSocket = (config: SocketConfig) => {
ws.on('CB:stream:error', (node: BinaryNode) => {
const [reasonNode] = getAllBinaryNodeChildren(node)
logger.error({ reasonNode, fullErrorNode: node }, 'stream errored out')
const { reason, statusCode } = getErrorCodeFromStreamError(node)
if(reason === 'device_removed') {
logger.error({ node }, 'stream error: device removed — logging out')
} else if(reason === 'xml-not-well-formed') {
logger.warn({ node }, 'stream error: sent malformed stanza (xml-not-well-formed)')
} else if(reason === 'ack') {
logger.warn({ ackId: reasonNode?.attrs?.id, node }, 'stream error: ack-based error')
} else {
logger.error({ reason, statusCode, node }, 'stream errored out')
}
void end(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }))
})
// stream fail, possible logout
+1 -1
View File
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
'app-state-sync-version': LTHashState
'lid-mapping': string
'device-list': string[]
tctoken: { token: Buffer; timestamp?: string }
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
/** Identity key for Signal Protocol - used for detecting contact reinstalls */
'identity-key': Uint8Array
}
+2 -1
View File
@@ -35,7 +35,8 @@ export enum DisconnectReason {
restartRequired = 515,
multideviceMismatch = 411,
forbidden = 403,
unavailableService = 503
unavailableService = 503,
sessionInvalidated = 516
}
export type WAInitResponse = {
+60
View File
@@ -947,4 +947,64 @@ export function logLidMapping(
}
}
/**
* Log tctoken lifecycle events
*
* @example
* logTcToken('fetch', { jid: '5511999999999@s.whatsapp.net' })
* // Output: [BAILEYS] 🔑 TcToken fetch → 5511999999999@s.whatsapp.net
*
* logTcToken('expired', { jid: '5511999999999@s.whatsapp.net', age: '32d' })
* // Output: [BAILEYS] 🔑 TcToken expired → 5511999999999@s.whatsapp.net { age: 32d }
*/
export function logTcToken(
event: 'stored' | 'expired' | 'fetch' | 'fetched' | 'reissue' | 'reissue_ok' | 'reissue_fail' | 'prune' | 'error_463' | 'error_479' | 'attached',
data?: Record<string, unknown>,
sessionName?: string
): void {
if (!isBaileysLogEnabled()) return
const prefix = sessionName ? `[BAILEYS] [${sessionName}]` : '[BAILEYS]'
const jid = data?.jid ? `${data.jid}` : ''
const rest = data ? { ...data } : undefined
if (rest) delete rest.jid
const extraStr = rest && Object.keys(rest).length > 0 ? ' ' + formatLogData(rest) : ''
switch (event) {
case 'stored':
console.log(`${prefix} 🔑 TcToken stored${jid}${extraStr}`)
break
case 'expired':
console.log(`${prefix} 🔑 TcToken expired${jid}${extraStr}`)
break
case 'fetch':
console.log(`${prefix} 🔑 TcToken fetch${jid}${extraStr}`)
break
case 'fetched':
console.log(`${prefix} 🔑 TcToken fetched${jid}${extraStr}`)
break
case 'reissue':
console.log(`${prefix} 🔑 TcToken reissue${jid}${extraStr}`)
break
case 'reissue_ok':
console.log(`${prefix} 🔑 TcToken reissue OK${jid}${extraStr}`)
break
case 'reissue_fail':
console.log(`${prefix} 🔑 TcToken reissue failed${jid}${extraStr}`)
break
case 'prune':
console.log(`${prefix} 🔑 TcToken prune${extraStr}`)
break
case 'attached':
console.log(`${prefix} 🔑 TcToken attached${jid}${extraStr}`)
break
case 'error_463':
console.log(`${prefix} ⚠️ TcToken missing (463)${jid}${extraStr}`)
break
case 'error_479':
console.log(`${prefix} ⚠️ TcToken smax-invalid (479)${jid}${extraStr}`)
break
}
}
export default BaileysLogger
+7
View File
@@ -111,6 +111,13 @@ export const NACK_REASONS = {
CorruptedSession: 553
}
export const SERVER_ERROR_CODES = {
MissingTcToken: '463',
SmaxInvalid: '479',
StaleGroupAddressingMode: '421',
NewChatMessagesCapped: '475'
}
type MessageType =
| 'chat'
| 'peer_broadcast'
+12 -2
View File
@@ -366,12 +366,22 @@ const CODE_MAP: { [_: string]: DisconnectReason } = {
export const getErrorCodeFromStreamError = (node: BinaryNode) => {
const [reasonNode] = getAllBinaryNodeChildren(node)
let reason = reasonNode?.tag || 'unknown'
const statusCode = +(node.attrs.code || CODE_MAP[reason] || DisconnectReason.badSession)
if (statusCode === DisconnectReason.restartRequired) {
// device_removed is a specific conflict type that means full logout
if(reason === 'conflict' && reasonNode?.attrs?.type === 'device_removed') {
return { reason: 'device_removed', statusCode: DisconnectReason.loggedOut }
}
const statusCode = +(reasonNode?.attrs?.code || node.attrs.code || CODE_MAP[reason] || DisconnectReason.badSession)
if(statusCode === DisconnectReason.restartRequired) {
reason = 'restart required'
}
if(statusCode === DisconnectReason.sessionInvalidated) {
reason = 'session invalidated'
}
return {
reason,
statusCode
+132 -5
View File
@@ -1,5 +1,66 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary'
import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, 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
/**
* Check if a received token is expired using WA Web's rolling bucket algorithm.
* Reference: WAWebTrustedContactsUtils.isTokenExpired
*
* Uses Receiver mode constants (tctoken_duration, tctoken_num_buckets).
* NOTE: WA Web distinguishes Sender vs Receiver mode via AB props
* (tctoken_duration_sender / tctoken_num_buckets_sender). Currently both
* use identical values (604800 / 4), so we use a single function for both.
* If WA ever diverges these, add a `mode` parameter here.
*/
export function isTcTokenExpired(timestamp: number | string | null | undefined): boolean {
if(timestamp === null || timestamp === undefined) return true
const ts = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp
if(isNaN(ts)) return true
const now = Math.floor(Date.now() / 1000)
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
const cutoffBucket = currentBucket - (TC_TOKEN_NUM_BUCKETS - 1)
const cutoffTimestamp = cutoffBucket * TC_TOKEN_BUCKET_DURATION
return ts < cutoffTimestamp
}
/**
* Check if we should issue a new token to this contact (bucket boundary crossed).
* Reference: WAWebTrustedContactsUtils.shouldSendNewToken
*
* Returns true if senderTimestamp is null/undefined or in a previous bucket.
*/
export function shouldSendNewTcToken(senderTimestamp: number | undefined): boolean {
if(senderTimestamp === undefined) return true
const now = Math.floor(Date.now() / 1000)
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
const senderBucket = Math.floor(senderTimestamp / TC_TOKEN_BUCKET_DURATION)
return currentBucket > senderBucket
}
/**
* Resolve a JID to its LID for tctoken storage, mirroring how Signal sessions
* use LID keys via resolveLIDSignalAddress.
*
* WA Web always resolves to LID before storing/looking up tctokens:
* `senderLid ?? toLid(from)` (WAWebSetTcTokenChatAction.handleIncomingTcToken)
*
* @param jid - The JID to resolve (can be PN or LID)
* @param getLIDForPN - Resolver function (from lidMapping)
* @returns The LID if mapping exists, otherwise the original JID
*/
export async function resolveTcTokenJid(
jid: string,
getLIDForPN: (pn: string) => Promise<string | null>
): Promise<string> {
if(isLidUser(jid)) return jid
const lid = await getLIDForPN(jid)
return lid ?? jid
}
type TcTokenParams = {
jid: string
@@ -7,19 +68,29 @@ type TcTokenParams = {
authState: {
keys: SignalKeyStoreWithTransaction
}
getLIDForPN?: (pn: string) => Promise<string | null>
}
export async function buildTcTokenFromJid({
authState,
jid,
baseContent = []
baseContent = [],
getLIDForPN
}: TcTokenParams): Promise<BinaryNode[] | undefined> {
try {
const tcTokenData = await authState.keys.get('tctoken', [jid])
const storageJid = getLIDForPN ? await resolveTcTokenJid(jid, getLIDForPN) : jid
const tcTokenData = await authState.keys.get('tctoken', [storageJid])
const entry = tcTokenData?.[storageJid]
const tcTokenBuffer = entry?.token
const tcTokenBuffer = tcTokenData?.[jid]?.token
if(!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) {
// Opportunistic cleanup: remove expired token from store
if(tcTokenBuffer) {
await authState.keys.set({ tctoken: { [storageJid]: null } })
}
if (!tcTokenBuffer) return baseContent.length > 0 ? baseContent : undefined
return baseContent.length > 0 ? baseContent : undefined
}
baseContent.push({
tag: 'tctoken',
@@ -28,7 +99,63 @@ export async function buildTcTokenFromJid({
})
return baseContent
} catch (error) {
} catch(error) {
return baseContent.length > 0 ? baseContent : undefined
}
}
type StoreTcTokensParams = {
result: BinaryNode
fallbackJid: string
keys: SignalKeyStoreWithTransaction
getLIDForPN: (pn: string) => Promise<string | null>
/** Optional callback when a new JID is stored (for index tracking) */
onNewJidStored?: (jid: string) => void
}
/**
* Parse and store tctoken(s) from an IQ result node.
* Includes timestamp monotonicity guard matching WA Web's handleIncomingTcToken.
* Used by both the blocking fetch (messages-send) and IQ response (messages-recv) paths.
*/
export async function storeTcTokensFromIqResult({
result,
fallbackJid,
keys,
getLIDForPN,
onNewJidStored
}: StoreTcTokensParams) {
const tokensNode = getBinaryNodeChild(result, 'tokens')
if(!tokensNode) return
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
for(const tokenNode of tokenNodes) {
if(tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) {
continue
}
const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid)
const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN)
const existingTcData = await keys.get('tctoken', [storageJid])
const existingEntry = existingTcData[storageJid]
// Timestamp monotonicity guard — only store if incoming timestamp >= existing
// Matches WA Web handleIncomingTcToken
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
const incomingTs = tokenNode.attrs.t ? Number(tokenNode.attrs.t) : 0
if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
continue
}
await keys.set({
tctoken: {
[storageJid]: {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t
}
}
})
onNewJidStored?.(storageJid)
}
}