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:
+132
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user