diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index 95f85d18..03db6e82 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -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', diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index bdf1937e..89c46805 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -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,86 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { let sendActiveReceipts = false + // ======= tctoken index tracking for cross-session pruning ======= + const TC_TOKEN_INDEX_KEY = '__index' + const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts' + const tcTokenKnownJids = new Set() + let tcTokenIndexSaveTimer: ReturnType | undefined + let lastTcTokenPruneTs = 0 + + // Load persisted JID index and last prune timestamp on startup + const tcTokenIndexLoaded = (async () => { + try { + const data = await authState.keys.get('tctoken', [TC_TOKEN_INDEX_KEY, TC_TOKEN_PRUNE_TS_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) + } + } + + const pruneEntry = data[TC_TOKEN_PRUNE_TS_KEY] + if(pruneEntry?.timestamp) { + lastTcTokenPruneTs = Number(pruneEntry.timestamp) + } + } 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 = {} + const survivingJids: string[] = [] + + const jidsToCheck = Array.from(tcTokenKnownJids).filter(j => j !== TC_TOKEN_INDEX_KEY) + if(!jidsToCheck.length) return + + try { + const allData = await authState.keys.get('tctoken', jidsToCheck) + for(const jid of jidsToCheck) { + const entry = allData[jid] + if(!entry?.token || isTcTokenExpired(entry.timestamp)) { + pruneSet[jid] = null + } else { + survivingJids.push(jid) + } + } + } catch { + return // batch read failed — skip this pruning cycle + } + + 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 +716,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 +1060,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 +1874,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 +1892,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 +2048,30 @@ 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 + // Persist prune timestamp so it survives restarts + Promise.resolve(authState.keys.set({ + tctoken: { + [TC_TOKEN_PRUNE_TS_KEY]: { + token: Buffer.alloc(0), + timestamp: now.toString() + } + } + })).catch(() => { /* non-critical */ }) + pruneExpiredTcTokens().catch(err => { + logger.debug({ err: err?.message }, 'tctoken pruning failed') + }) + } + } } }) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 5bc36176..edf340b1 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -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,103 @@ 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), + onNewJidStored: (storedJid) => { + // Fire-and-forget: persist JID into tctoken index for pruning + (async () => { + try { + const TC_IDX = '__index' + const idxData = await authState.keys.get('tctoken', [TC_IDX]) + const idxEntry = idxData[TC_IDX] + const existing: string[] = idxEntry?.token + ? JSON.parse(Buffer.from(idxEntry.token).toString('utf8')) + : [] + if(!existing.includes(storedJid)) { + existing.push(storedJid) + await authState.keys.set({ + tctoken: { + [TC_IDX]: { + token: Buffer.from(JSON.stringify(existing), 'utf8'), + timestamp: unixTimestampSeconds().toString() + } + } + }) + } + } catch { /* non-critical — index rebuilt on next pruning cycle */ } + })() + } + }) + + // 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 +1659,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 +1800,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: { diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index eb287fe4..75f69d7b 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -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 diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts index 870fbbdd..0f9bf2ac 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -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 } diff --git a/src/Types/index.ts b/src/Types/index.ts index db209221..13f09aea 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -35,7 +35,8 @@ export enum DisconnectReason { restartRequired = 515, multideviceMismatch = 411, forbidden = 403, - unavailableService = 503 + unavailableService = 503, + sessionInvalidated = 516 } export type WAInitResponse = { diff --git a/src/Utils/baileys-logger.ts b/src/Utils/baileys-logger.ts index ed33e6d4..80eb2a1a 100644 --- a/src/Utils/baileys-logger.ts +++ b/src/Utils/baileys-logger.ts @@ -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, + 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 diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index 065f50a9..3457eb84 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -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' diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index 997b6b8e..570cb2eb 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -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 diff --git a/src/Utils/tc-token-utils.ts b/src/Utils/tc-token-utils.ts index 0e5df545..8d3fc956 100644 --- a/src/Utils/tc-token-utils.ts +++ b/src/Utils/tc-token-utils.ts @@ -1,5 +1,67 @@ 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' ? Number(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 +): Promise { + const normalized = jidNormalizedUser(jid) + if(isLidUser(normalized)) return normalized + const lid = await getLIDForPN(normalized) + return lid ?? normalized +} type TcTokenParams = { jid: string @@ -7,19 +69,29 @@ type TcTokenParams = { authState: { keys: SignalKeyStoreWithTransaction } + getLIDForPN?: (pn: string) => Promise } export async function buildTcTokenFromJid({ authState, jid, - baseContent = [] + baseContent = [], + getLIDForPN }: TcTokenParams): Promise { 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 +100,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 + /** 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) + } +}