diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index 46685d42..2b3bf86a 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -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, diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 77aa0118..f9e95788 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -73,7 +73,13 @@ import { recordMessageReceived, recordMessageRetry } from '../Utils/prometheus-metrics.js' -import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils' +import { + isTcTokenExpired, + resolveIssuanceJid, + resolveTcTokenJid, + storeTcTokensFromIqResult, + TC_TOKEN_INDEX_KEY +} from '../Utils/tc-token-utils' import { areJidsSameUser, type BinaryNode, @@ -164,7 +170,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() const tcTokenRetriedMsgIds = new Set() @@ -1434,6 +1445,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) racets 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 +1516,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') } diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index c4974d93..6974809e 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -46,7 +46,9 @@ import { makeKeyedMutex } from '../Utils/make-mutex' import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics' import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils' import { + buildMergedTcTokenIndexWrite, isTcTokenExpired, + resolveIssuanceJid, resolveTcTokenJid, shouldSendNewTcToken, storeTcTokensFromIqResult @@ -64,12 +66,14 @@ import { isHostedPnUser, isJidBot, isJidGroup, + isJidMetaAI, isLidUser, isPnUser, jidDecode, jidEncode, jidNormalizedUser, type JidWithDevice, + PSA_WID, S_WHATSAPP_NET } from '../WABinary' import { USyncQuery, USyncUser } from '../WAUSync' @@ -125,6 +129,15 @@ export const makeMessagesSocket = (config: SocketConfig) => { // Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches const tcTokenFetchingJids = new Set() + /** + * 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() + let mediaConn: Promise const refreshMediaConn = async (forceGet = false) => { const media = await mediaConn @@ -1715,7 +1728,10 @@ 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. + if (tcTokenBuffer?.length && sock.serverProps.privacyTokenOn1to1) { ;(stanza.content as BinaryNode[]).push({ tag: 'tctoken', attrs: {}, @@ -1815,13 +1831,35 @@ 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 + const isBotOrPSA = destinationJid === PSA_WID || isJidBot(destinationJid) || isJidMetaAI(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 +1870,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 +1882,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { token: currentEntry?.token ?? Buffer.alloc(0), senderTimestamp: issueTimestamp, realIssueTimestamp: 0 - } + }, + ...indexWrite } }) @@ -1851,6 +1892,9 @@ export const makeMessagesSocket = (config: SocketConfig) => { .catch(err => { logTcToken('reissue_fail', { jid: destinationJid, error: err?.message }) }) + .finally(() => { + inFlightTcTokenIssuance.delete(tcTokenJid) + }) } // Log with [BAILEYS] prefix diff --git a/src/Utils/identity-change-handler.ts b/src/Utils/identity-change-handler.ts index 4dad2b7a..d3a81111 100644 --- a/src/Utils/identity-change-handler.ts +++ b/src/Utils/identity-change-handler.ts @@ -57,6 +57,16 @@ export type IdentityChangeContext = { debounceCache: NodeCache /** Logger instance for debugging and monitoring */ logger: ILogger + /** + * Invoked right before `assertSessions` is called for an existing-session identity + * change. Used to kick off fire-and-forget side effects (e.g. tctoken re-issuance) + * in the same order WA Web does — i.e. before the E2E session is re-established. + * Must not throw; implementations are responsible for their own error handling. + * + * Skipped when the refresh itself is skipped (no_identity_node, invalid_notification, + * skipped_companion_device, skipped_self_primary, debounced, skipped_offline). + */ + onBeforeSessionRefresh?: (jid: string) => void } // ============================================================================ @@ -170,6 +180,11 @@ export async function handleIdentityChange( // This ensures we don't incorrectly debounce when we exit early (offline, etc.) ctx.debounceCache.set(from, true) + // Fire-and-forget side effects (e.g. tctoken re-issuance) BEFORE the session is + // re-established. WA Web runs these in parallel with the session refresh — + // running afterwards would race with the next outbound send and risk error 463. + ctx.onBeforeSessionRefresh?.(from) + // Attempt session refresh/creation try { await ctx.assertSessions([from], true) diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index 04ec7f5e..0b47ad28 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -40,6 +40,7 @@ import { getKeyAuthor, toNumber } from './generics' import { downloadAndProcessHistorySyncNotification } from './history' import type { ILogger } from './logger' import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js' +import { buildMergedTcTokenIndexWrite, resolveTcTokenJid } from './tc-token-utils' type ProcessMessageContext = { shouldProcessHistoryMsg: boolean @@ -62,6 +63,85 @@ const REAL_MSG_STUB_TYPES = new Set([ const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD]) +/** + * Extract tctoken / tcTokenTimestamp / tcTokenSenderTimestamp from history-sync chats + * and persist them to the `tctoken` store. Mirrors WA Web's `bulkCreateOrMerge` pass + * over the chat table during history sync. + * + * Why this matters: when a user logs in on a new device, the multi-device history sync + * is the only way that device learns about tctokens issued/received on the original + * device. Without this pass, the new device sends 1:1 messages with no tctoken until + * the contact triggers a fresh notification — which surfaces as error 463 in production. + * + * Monotonicity: we only overwrite an existing entry if the incoming timestamp is + * STRICTLY newer (`incoming > existing`). Equal timestamps are skipped to avoid + * reverting senderTimestamp / realIssueTimestamp set by other layers (e.g. a + * reissue that fired between history-sync chunks). + * + * Index hygiene: every JID we write here is added to the persistent prune index + * (TC_TOKEN_INDEX_KEY) via buildMergedTcTokenIndexWrite, so the 24h prune sweep in + * messages-recv picks them up across sessions. + */ +async function storeTcTokensFromHistorySync( + chats: Chat[], + signalRepository: SignalRepositoryWithLIDStore, + keyStore: SignalKeyStoreWithTransaction, + logger?: ILogger +) { + const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping) + + const candidates: { storageJid: string; token: Buffer; ts: number; senderTs?: number }[] = [] + for (const chat of chats) { + const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0 + if (chat.tcToken?.length && ts > 0) { + const jid = jidNormalizedUser(chat.id!) + const storageJid = await resolveTcTokenJid(jid, getLIDForPN) + candidates.push({ + storageJid, + token: Buffer.from(chat.tcToken), + ts, + senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined + }) + } + } + + if (!candidates.length) { + return + } + + const jids = candidates.map(c => c.storageJid) + const existing = await keyStore.get('tctoken', jids) + const entries: Record = {} + + for (const c of candidates) { + const existingEntry = existing[c.storageJid] + const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0 + // Strict > guard: equal timestamps are skipped so we never clobber + // senderTimestamp written by other layers (issuance after send, etc). + if (existingTs > 0 && existingTs >= c.ts) { + continue + } + + entries[c.storageJid] = { + ...existingEntry, + token: c.token, + timestamp: String(c.ts), + ...(c.senderTs !== undefined ? { senderTimestamp: c.senderTs } : {}) + } + } + + if (Object.keys(entries).length) { + logger?.debug({ count: Object.keys(entries).length }, 'storing tctokens from history sync') + try { + // Include updated __index so cross-session pruning picks these JIDs up. + const indexWrite = await buildMergedTcTokenIndexWrite(keyStore, Object.keys(entries)) + await keyStore.set({ tctoken: { ...entries, ...indexWrite } }) + } catch (err) { + logger?.warn({ err }, 'failed to store tctokens from history sync') + } + } +} + /** Cleans a received message to further processing */ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => { // ensure remoteJid and participant doesn't have device or agent in it @@ -416,6 +496,11 @@ const processMessage = async ( const data = await downloadAndProcessHistorySyncNotification(histNotification, options, logger) + // Persist tctokens carried by history-sync chats BEFORE emitting messaging-history.set + // — listeners may immediately fire outbound sends that need the tctoken, and the store + // has to be populated first to avoid an error 463 on the first multi-device send. + await storeTcTokensFromHistorySync(data.chats, signalRepository, keyStore, logger) + // Emit LID-PN mappings from history sync // This is how WhatsApp Web learns mappings for chats with non-contacts if (data.lidPnMappings?.length) { diff --git a/src/Utils/tc-token-utils.ts b/src/Utils/tc-token-utils.ts index b2a68fcc..392326ff 100644 --- a/src/Utils/tc-token-utils.ts +++ b/src/Utils/tc-token-utils.ts @@ -1,12 +1,84 @@ import type { SignalKeyStoreWithTransaction } from '../Types' import type { BinaryNode } from '../WABinary' -import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidNormalizedUser } from '../WABinary' +import { + getBinaryNodeChild, + getBinaryNodeChildren, + isHostedLidUser, + isHostedPnUser, + isJidMetaAI, + isLidUser, + isPnUser, + 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 +/** + * Sentinel key under the `tctoken` store holding a JSON array of tracked storage JIDs + * for cross-session pruning. Mirrors WA Web's CLEAN_TC_TOKENS index lookup. + * + * Exported so other modules (messages-recv, messages-send, process-message) reference + * the same constant. The fork previously inlined this string in messages-recv.ts; + * keep the value identical (`'__index'`) for backward compatibility with persisted state. + */ +export const TC_TOKEN_INDEX_KEY = '__index' + +// Phone-number pattern matching WABinary's isJidBot, applied against the user part so +// the check is invariant to @c.us ↔ @s.whatsapp.net normalization. +const BOT_PHONE_REGEX = /^1313555\d{4}$|^131655500\d{2}$/ + +/** + * Mirrors WA Web's `Wid.isRegularUser()` (user ∧ ¬PSA ∧ ¬Bot). Used to gate tctoken + * storage against malformed notifications — WA Web filters server-side but we + * defend here for parity with `WAWebSetTcTokenChatAction.handleIncomingTcToken`. + * Works for both pre- and post-normalized JIDs (`@c.us` vs `@s.whatsapp.net`). + */ +export function isRegularUser(jid: string | undefined): boolean { + if (!jid) return false + const user = jid.split('@')[0] ?? '' + if (user === '0') return false // PSA + if (BOT_PHONE_REGEX.test(user)) return false // Bot by phone pattern + if (isJidMetaAI(jid)) return false // MetaAI (@bot server) + return !!(isPnUser(jid) || isLidUser(jid) || isHostedPnUser(jid) || isHostedLidUser(jid) || jid.endsWith('@c.us')) +} + +/** Read the persisted tctoken JID index and return its entries (never contains the sentinel key itself). */ +export async function readTcTokenIndex(keys: SignalKeyStoreWithTransaction): Promise { + const data = await keys.get('tctoken', [TC_TOKEN_INDEX_KEY]) + const entry = data[TC_TOKEN_INDEX_KEY] + if (!entry?.token?.length) return [] + try { + const parsed = JSON.parse(Buffer.from(entry.token).toString()) + if (!Array.isArray(parsed)) return [] + return parsed.filter((j): j is string => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY) + } catch { + return [] + } +} + +/** + * Build a SignalDataSet fragment that writes the merged index (persisted ∪ added) + * under the sentinel key. Lets callers update the index without clobbering writes + * made by other layers (history sync, concurrent sessions on the same store). + */ +export async function buildMergedTcTokenIndexWrite( + keys: SignalKeyStoreWithTransaction, + addedJids: Iterable +): Promise<{ [TC_TOKEN_INDEX_KEY]: { token: Buffer } }> { + const persisted = await readTcTokenIndex(keys) + const merged = new Set(persisted) + for (const jid of addedJids) { + if (jid && jid !== TC_TOKEN_INDEX_KEY) merged.add(jid) + } + + return { + [TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...merged])) } + } +} + /** * Check if a received token is expired using WA Web's rolling bucket algorithm. * Reference: WAWebTrustedContactsUtils.isTokenExpired @@ -63,6 +135,35 @@ export async function resolveTcTokenJid( return lid ?? normalized } +/** + * Resolve target JID for issuing privacy token based on AB prop 14303 + * (`lid_trusted_token_issue_to_lid`). When the prop is on, issuance goes to + * the LID; when off, it goes to the PN. Returns the original JID if no + * mapping is found in either direction. + * + * Reference: WAWebTrustedContactsManager.issuePrivacyTokens + */ +export async function resolveIssuanceJid( + jid: string, + issueToLid: boolean, + getLIDForPN: (pn: string) => Promise, + getPNForLID?: (lid: string) => Promise +): Promise { + if (issueToLid) { + if (isLidUser(jid)) return jid + const lid = await getLIDForPN(jid) + return lid ?? jid + } + + if (!isLidUser(jid)) return jid + if (getPNForLID) { + const pn = await getPNForLID(jid) + return pn ?? jid + } + + return jid +} + type TcTokenParams = { jid: string baseContent?: BinaryNode[] @@ -135,7 +236,13 @@ export async function storeTcTokensFromIqResult({ continue } - const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid) + // In notifications, tokenNode.attrs.jid is OUR own device JID, not the sender's. + // Prefer fallbackJid (resolved from notification's `from` / `sender_lid`) so the + // token is stored under the peer's JID, never under self. + const rawJid = jidNormalizedUser(fallbackJid || tokenNode.attrs.jid) + // Defense against malformed notifications (PSA WID '0', bots, MetaAI). WA Web + // filters these server-side; we mirror Wid.isRegularUser() locally. + if (!isRegularUser(rawJid)) continue const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN) const existingTcData = await keys.get('tctoken', [storageJid]) const existingEntry = existingTcData[storageJid] diff --git a/src/__tests__/Socket/identity-change-handling.test.ts b/src/__tests__/Socket/identity-change-handling.test.ts index 67c3cfe3..7849197a 100644 --- a/src/__tests__/Socket/identity-change-handling.test.ts +++ b/src/__tests__/Socket/identity-change-handling.test.ts @@ -297,4 +297,52 @@ describe('Identity Change Handling', () => { expect(result.device).toBe(5) }) }) + + describe('onBeforeSessionRefresh callback', () => { + it('fires before assertSessions when a session refresh is about to run', async () => { + mockValidateSession.mockResolvedValue({ exists: true }) + const callOrder: string[] = [] + mockAssertSessions.mockImplementation(async () => { + callOrder.push('assertSessions') + return true + }) + const onBeforeSessionRefresh = jest.fn((jid: string) => { + callOrder.push(`before:${jid}`) + }) + + const node = createIdentityChangeNode('user@s.whatsapp.net') + const ctx = { ...createContext(), onBeforeSessionRefresh } + const result = await handleIdentityChange(node, ctx) + + expect(result.action).toBe('session_refreshed') + expect(callOrder).toEqual(['before:user@s.whatsapp.net', 'assertSessions']) + }) + + it('does not fire when the refresh is skipped (no_identity / offline / self)', async () => { + const onBeforeSessionRefresh = jest.fn() + + // no identity node + const noIdentityNode: BinaryNode = { + tag: 'notification', + attrs: { from: 'a@s.whatsapp.net', type: 'encrypt' }, + content: [] + } + await handleIdentityChange(noIdentityNode, { ...createContext(), onBeforeSessionRefresh }) + + // offline notification + mockValidateSession.mockResolvedValue({ exists: true }) + await handleIdentityChange(createIdentityChangeNode('b@s.whatsapp.net', '0'), { + ...createContext(), + onBeforeSessionRefresh + }) + + // self primary + await handleIdentityChange(createIdentityChangeNode(mockMeId!), { + ...createContext(), + onBeforeSessionRefresh + }) + + expect(onBeforeSessionRefresh).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/__tests__/Utils/tc-token.test.ts b/src/__tests__/Utils/tc-token.test.ts index 8c53ea1f..924542d2 100644 --- a/src/__tests__/Utils/tc-token.test.ts +++ b/src/__tests__/Utils/tc-token.test.ts @@ -1,7 +1,17 @@ import { jest } from '@jest/globals' import { DisconnectReason, type SignalKeyStoreWithTransaction } from '../../Types' import { getErrorCodeFromStreamError, SERVER_ERROR_CODES } from '../../Utils' -import { buildTcTokenFromJid, isTcTokenExpired, shouldSendNewTcToken } from '../../Utils/tc-token-utils' +import { + buildMergedTcTokenIndexWrite, + buildTcTokenFromJid, + isRegularUser, + isTcTokenExpired, + readTcTokenIndex, + resolveIssuanceJid, + shouldSendNewTcToken, + storeTcTokensFromIqResult, + TC_TOKEN_INDEX_KEY +} from '../../Utils/tc-token-utils' import type { BinaryNode } from '../../WABinary' /** 7 days in seconds — matches WA Web tctoken_duration */ @@ -848,3 +858,312 @@ describe('tctoken integration scenarios', () => { }) }) }) + +// ─── isRegularUser (PSA / bot / MetaAI gating) ───────────────────────── + +describe('isRegularUser', () => { + it('rejects undefined / empty', () => { + expect(isRegularUser(undefined)).toBe(false) + expect(isRegularUser('')).toBe(false) + }) + + it('rejects PSA WID (user "0")', () => { + expect(isRegularUser('0@s.whatsapp.net')).toBe(false) + expect(isRegularUser('0@c.us')).toBe(false) + }) + + it('rejects bot phone numbers (1313555XXXX, 131655500XX)', () => { + expect(isRegularUser('13135550000@s.whatsapp.net')).toBe(false) + expect(isRegularUser('13135559999@s.whatsapp.net')).toBe(false) + // /^131655500\d{2}$/ — 11 digits: 131655500 + 2 trailing + expect(isRegularUser('13165550000@s.whatsapp.net')).toBe(false) + expect(isRegularUser('13165550099@s.whatsapp.net')).toBe(false) + }) + + it('rejects MetaAI (@bot server)', () => { + expect(isRegularUser('foo@bot')).toBe(false) + }) + + it('accepts regular PN users (@s.whatsapp.net, @c.us)', () => { + expect(isRegularUser('5511999999999@s.whatsapp.net')).toBe(true) + expect(isRegularUser('5511999999999@c.us')).toBe(true) + }) + + it('accepts LID users', () => { + expect(isRegularUser('123456@lid')).toBe(true) + }) + + it('accepts hosted PN/LID users', () => { + expect(isRegularUser('5511999999999@hosted')).toBe(true) + expect(isRegularUser('123456@hosted.lid')).toBe(true) + }) +}) + +// ─── resolveIssuanceJid (AB prop 14303 routing) ──────────────────────── + +describe('resolveIssuanceJid', () => { + const PN = '5511999999999@s.whatsapp.net' + const LID = '123456@lid' + const PN_NO_LID = '5511888888888@s.whatsapp.net' + const LID_NO_PN = '999999@lid' + + const getLIDForPN = jest.fn<(pn: string) => Promise>(async (pn: string) => { + if (pn === PN) return LID + return null + }) + const getPNForLID = jest.fn<(lid: string) => Promise>(async (lid: string) => { + if (lid === LID) return PN + return null + }) + + beforeEach(() => { + getLIDForPN.mockClear() + getPNForLID.mockClear() + }) + + describe('AB prop 14303 ON (issueToLid=true)', () => { + it('returns LID unchanged when input is LID', async () => { + expect(await resolveIssuanceJid(LID, true, getLIDForPN, getPNForLID)).toBe(LID) + expect(getLIDForPN).not.toHaveBeenCalled() + }) + + it('resolves PN to LID via lidMapping', async () => { + expect(await resolveIssuanceJid(PN, true, getLIDForPN, getPNForLID)).toBe(LID) + expect(getLIDForPN).toHaveBeenCalledWith(PN) + }) + + it('falls back to original PN when no LID mapping exists', async () => { + expect(await resolveIssuanceJid(PN_NO_LID, true, getLIDForPN, getPNForLID)).toBe(PN_NO_LID) + }) + }) + + describe('AB prop 14303 OFF (issueToLid=false)', () => { + it('returns PN unchanged when input is PN', async () => { + expect(await resolveIssuanceJid(PN, false, getLIDForPN, getPNForLID)).toBe(PN) + expect(getPNForLID).not.toHaveBeenCalled() + }) + + it('resolves LID to PN via lidMapping', async () => { + expect(await resolveIssuanceJid(LID, false, getLIDForPN, getPNForLID)).toBe(PN) + expect(getPNForLID).toHaveBeenCalledWith(LID) + }) + + it('falls back to original LID when no PN mapping exists', async () => { + expect(await resolveIssuanceJid(LID_NO_PN, false, getLIDForPN, getPNForLID)).toBe(LID_NO_PN) + }) + + it('returns LID unchanged when getPNForLID is omitted', async () => { + expect(await resolveIssuanceJid(LID, false, getLIDForPN)).toBe(LID) + }) + }) +}) + +// ─── readTcTokenIndex / buildMergedTcTokenIndexWrite ─────────────────── + +describe('tctoken cross-session prune index', () => { + it('exports the sentinel key as "__index"', () => { + // Persisted state compatibility — tests freeze the value to catch unintended renames. + expect(TC_TOKEN_INDEX_KEY).toBe('__index') + }) + + describe('readTcTokenIndex', () => { + it('returns [] when the index entry is absent', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({}) + expect(await readTcTokenIndex(keys)).toEqual([]) + }) + + it('returns [] when the index token buffer is empty', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.alloc(0) } }) + expect(await readTcTokenIndex(keys)).toEqual([]) + }) + + it('returns [] when the JSON payload is corrupted', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('not-json') } }) + expect(await readTcTokenIndex(keys)).toEqual([]) + }) + + it('returns [] when the JSON payload is not an array', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('{"a":1}') } }) + expect(await readTcTokenIndex(keys)).toEqual([]) + }) + + it('returns the persisted JIDs', async () => { + const keys = createMockKeys() + const stored = ['a@lid', 'b@lid', 'c@s.whatsapp.net'] + ;(keys.get as any).mockResolvedValue({ + [TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) } + }) + expect(await readTcTokenIndex(keys)).toEqual(stored) + }) + + it('filters out the sentinel key itself, empty strings, and non-strings', async () => { + const keys = createMockKeys() + const stored = ['a@lid', '', TC_TOKEN_INDEX_KEY, 42, null, 'b@lid'] + ;(keys.get as any).mockResolvedValue({ + [TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) } + }) + expect(await readTcTokenIndex(keys)).toEqual(['a@lid', 'b@lid']) + }) + }) + + describe('buildMergedTcTokenIndexWrite', () => { + it('merges added JIDs with the persisted set (de-duplicated)', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({ + [TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(['a@lid', 'b@lid'])) } + }) + const write = await buildMergedTcTokenIndexWrite(keys, ['b@lid', 'c@lid']) + const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[] + expect(decoded.sort()).toEqual(['a@lid', 'b@lid', 'c@lid']) + }) + + it('drops the sentinel key from the added set', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({}) + const write = await buildMergedTcTokenIndexWrite(keys, [TC_TOKEN_INDEX_KEY, 'a@lid']) + const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[] + expect(decoded).toEqual(['a@lid']) + }) + + it('handles empty inputs', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({}) + const write = await buildMergedTcTokenIndexWrite(keys, []) + const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[] + expect(decoded).toEqual([]) + }) + }) +}) + +// ─── storeTcTokensFromIqResult — isRegularUser gating + monotonicity ─── + +describe('storeTcTokensFromIqResult — gating and monotonicity', () => { + const PEER_PN = '5511999999999@s.whatsapp.net' + const PEER_LID = '123456@lid' + + const getLIDForPN = jest.fn<(pn: string) => Promise>(async (pn: string) => { + if (pn === PEER_PN) return PEER_LID + return null + }) + + beforeEach(() => { + getLIDForPN.mockClear() + }) + + const buildIqResult = (jid: string, tokenBytes: Uint8Array, t: string): BinaryNode => ({ + tag: 'iq', + attrs: {}, + content: [ + { + tag: 'tokens', + attrs: {}, + content: [ + { + tag: 'token', + attrs: { jid, type: 'trusted_contact', t }, + content: tokenBytes + } + ] + } + ] + }) + + it('skips PSA WID (jid="0") even if returned in tokens block', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({}) + await storeTcTokensFromIqResult({ + result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'), + fallbackJid: '0@s.whatsapp.net', + keys, + getLIDForPN + }) + expect(keys.set).not.toHaveBeenCalled() + }) + + it('skips bot phone numbers', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({}) + await storeTcTokensFromIqResult({ + result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'), + fallbackJid: '13135550000@s.whatsapp.net', + keys, + getLIDForPN + }) + expect(keys.set).not.toHaveBeenCalled() + }) + + it('skips MetaAI (@bot server)', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({}) + await storeTcTokensFromIqResult({ + result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'), + fallbackJid: 'meta-ai@bot', + keys, + getLIDForPN + }) + expect(keys.set).not.toHaveBeenCalled() + }) + + it('uses fallbackJid (NOT the token node jid which is own device) as storage source', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({}) + const onNewJidStored = jest.fn() + await storeTcTokensFromIqResult({ + result: buildIqResult('myDeviceJid:1@s.whatsapp.net', Buffer.from([0xaa, 0xbb]), '1700000000'), + fallbackJid: PEER_PN, + keys, + getLIDForPN, + onNewJidStored + }) + expect(keys.set).toHaveBeenCalled() + const setArgs = (keys.set.mock.calls[0]?.[0] as { tctoken: Record }).tctoken + expect(Object.keys(setArgs)).toContain(PEER_LID) + expect(onNewJidStored).toHaveBeenCalledWith(PEER_LID) + }) + + it('skips when incoming timestamp is older than existing (monotonicity)', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({ + [PEER_LID]: { token: Buffer.from([0x99]), timestamp: '1800000000' } + }) + await storeTcTokensFromIqResult({ + result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0xaa]), '1700000000'), + fallbackJid: PEER_PN, + keys, + getLIDForPN + }) + expect(keys.set).not.toHaveBeenCalled() + }) + + it('skips timestamp-less tokens (would be immediately expired)', async () => { + const keys = createMockKeys() + ;(keys.get as any).mockResolvedValue({}) + await storeTcTokensFromIqResult({ + result: { + tag: 'iq', + attrs: {}, + content: [ + { + tag: 'tokens', + attrs: {}, + content: [ + { + tag: 'token', + attrs: { jid: 'd@s.whatsapp.net', type: 'trusted_contact' }, + content: Buffer.from([0x01]) + } + ] + } + ] + }, + fallbackJid: PEER_PN, + keys, + getLIDForPN + }) + expect(keys.set).not.toHaveBeenCalled() + }) +})