diff --git a/src/Signal/lid-mapping.ts b/src/Signal/lid-mapping.ts index 5dc510be5ad..25e30c38997 100644 --- a/src/Signal/lid-mapping.ts +++ b/src/Signal/lid-mapping.ts @@ -505,6 +505,53 @@ export class LIDMappingStore { }) } + /** + * Port of upstream PR #2614 (`fix: nest profile picture tctoken and avoid + * usync on lookup`). Returns the LID for a PN ONLY if the mapping is + * already known (memory cache or on-disk store). Never triggers a USync + * lookup. + * + * Use this on hot paths where firing a USync just to opportunistically + * attach metadata (e.g. profile-picture tctoken) is undesired — both + * because the latency is wasted (the operation must still proceed if the + * mapping is unknown) AND because USync-on-look-up is a behavioral + * fingerprint WA Web / whatsmeow don't emit, so doing it makes our + * traffic profile stand out and may serve as a ban signal. + * + * Thread safety: wrapped in `checkDestroyed()` + `trackOperation()` — + * same contract every other public method on this store follows. Without + * these, the async `keys.get()` could race with `destroy()` (UAF on the + * key store) and a post-destroy call could silently return stale data. + * (PR #510 review — addresses cubic / copilot P2.) + */ + async getKnownLIDForPN(pn: string): Promise { + this.checkDestroyed() + + return this.trackOperation(async () => { + if (!isAnyPnUser(pn)) return null + + const decoded = jidDecode(pn) + if (!decoded) return null + + const pnUser = decoded.user + let lidUser = this.mappingCache.get(`pn:${pnUser}`) + if (!lidUser) { + const stored = await this.keys.get('lid-mapping', [pnUser]) + const storedLidUser = stored[pnUser] + if (typeof storedLidUser === 'string' && storedLidUser) { + lidUser = storedLidUser + this.mappingCache.set(`pn:${pnUser}`, lidUser) + this.mappingCache.set(`lid:${lidUser}`, pnUser) + } + } + + if (!lidUser) return null + + const pnDevice = decoded.device !== undefined ? decoded.device : 0 + return `${lidUser}${pnDevice ? `:${pnDevice}` : ''}@${decoded.server === 'hosted' ? 'hosted.lid' : 'lid'}` + }) + } + /** * Get LIDs for multiple PNs - Optimized batch operation * diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index 977ae73cdd9..9f2c3ef550d 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -55,7 +55,7 @@ import { } from '../Utils' import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex' import processMessage from '../Utils/process-message' -import { buildTcTokenFromJid } from '../Utils/tc-token-utils' +import { buildTcTokenFromJid, buildTcTokenNode } from '../Utils/tc-token-utils' import { type BinaryNode, getBinaryNodeChild, @@ -96,6 +96,14 @@ export const makeChatsSocket = (config: SocketConfig) => { } = sock const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping) + /** + * Local-only LID resolver (port of upstream PR #2614). Use on + * profile-picture / similar paths where we OPPORTUNISTICALLY attach + * metadata: a USync miss should NOT bubble out as a network round-trip + * because that traffic profile diverges from WA Web / whatsmeow and + * smells like a custom client. + */ + const getKnownLIDForPN = signalRepository.lidMapping.getKnownLIDForPN.bind(signalRepository.lidMapping) let privacySettings: { [_: string]: string } | undefined @@ -784,43 +792,88 @@ export const makeChatsSocket = (config: SocketConfig) => { ) /** - * fetch the profile picture of a user/group - * type = "preview" for a low res picture - * type = "image for the high res picture" + * Fetch the profile picture URL of a user/group. + * + * `type`: "preview" for a low-res picture, "image" for the high-res picture. + * `existingId`: the `id` of the last known picture for this JID. If supplied AND the + * picture has not changed, the server responds with a `304`-style empty + * result (sentinel for "use cached URL") instead of returning a fresh URL. + * Saves a CDN re-fetch per refresh — matches WA Web `pictureId` and + * whatsmeow `ExistingID`. + * `invite`: group-invite code. Allows fetching a group's picture without joining, + * used by the "preview before accepting invite" flow. Mutually exclusive + * with `tctoken` (server doesn't require it for invite-code lookups). + * `personaId`: Meta-AI bot persona id. Required to fetch the picture of an AI persona. + * `commonGid`: a group jid both parties belong to. Required when the target's privacy + * is set to "My contacts" and we are NOT in their contacts but share a + * group — without it the server returns 401/403. Matches WA Web. + * + * Stanza shape (matches WA Web `WASmaxOutProfilePictureGetRequest` + whatsmeow): + * + * + * + * [...] ← nested CHILD (not sibling) + * + * */ - const profilePictureUrl = async (jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => { - const baseContent: BinaryNode[] = [{ tag: 'picture', attrs: { type, query: 'url' } }] - - // 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 profilePictureUrl = async ( + jid: string, + type: 'preview' | 'image' = 'preview', + timeoutMs?: number, + opts?: { + existingId?: string + invite?: string + personaId?: string + commonGid?: string + } + ) => { const normalizedJid = jidNormalizedUser(jid) const isUserJid = isAnyPnUser(normalizedJid) || isAnyLidUser(normalizedJid) const me = authState.creds.me const isSelf = me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid))) - let content: BinaryNode[] | undefined = baseContent - if (isUserJid && !isSelf) { - content = await buildTcTokenFromJid({ + // Build the attrs — include only the fields the caller supplied, + // so unset ones map to DROP_ATTR (matching WA Web's OPTIONAL serializer behavior). + const pictureAttrs: { [k: string]: string } = { type, query: 'url' } + if (opts?.existingId) pictureAttrs.id = opts.existingId + if (opts?.invite) pictureAttrs.invite = opts.invite + if (opts?.personaId) pictureAttrs.persona_id = opts.personaId + if (opts?.commonGid) pictureAttrs.common_gid = opts.commonGid + + const pictureNode: BinaryNode = { tag: 'picture', attrs: pictureAttrs } + + // Attach tctoken (if known) as a CHILD of . Match WA Web + // (WASmaxOutProfilePictureTCTokenMixin) and whatsmeow (pictureContent). + // WA Web only includes tctoken for user JIDs (not groups/newsletters) + // and never for own profile pic — including it for self causes the + // server to never respond. Invite-code lookups also skip the token + // (the invite IS the authorization). + if (isUserJid && !isSelf && !opts?.invite) { + const tctokenNode = await buildTcTokenNode({ authState, jid: normalizedJid, - baseContent, - getLIDForPN + // Port of upstream PR #2614: never fire USync from the profile-picture + // path. If the LID mapping is unknown we send the IQ without the + // tctoken and let the server tell us (vs. doing a USync round trip + // that fingerprints us as non-WA-Web). + getLIDForPN: getKnownLIDForPN }) + if (tctokenNode) { + pictureNode.content = [tctokenNode] + } } - jid = normalizedJid const result = await query( { tag: 'iq', attrs: { - target: jid, + target: normalizedJid, to: S_WHATSAPP_NET, type: 'get', xmlns: 'w:profile:picture' }, - content + content: [pictureNode] }, timeoutMs ) diff --git a/src/Utils/tc-token-utils.ts b/src/Utils/tc-token-utils.ts index 9e67239ac74..fba21f36c90 100644 --- a/src/Utils/tc-token-utils.ts +++ b/src/Utils/tc-token-utils.ts @@ -101,12 +101,48 @@ type TcTokenParams = { getLIDForPN?: (pn: string) => Promise } -export async function buildTcTokenFromJid({ +/** + * Resolved tctoken state for a JID, shared by the two public builders. + * + * - `buffer`: present iff a non-expired, non-empty token exists in store. + * - Absent `buffer`: caller produces a "no tctoken" result (the shape of + * that result differs per builder — array vs single node). + * + * The helper is also responsible for the opportunistic expired-token wipe + * documented inline. Callers must NOT re-do that bookkeeping. + */ +type ResolvedTcToken = { buffer?: Buffer } + +/** + * Shared retrieval + expiry + opportunistic-cleanup pipeline used by both + * `buildTcTokenFromJid` (sibling-array shape, kept for legacy call sites) + * and `buildTcTokenNode` (single-node shape, used for nested tctoken in + * ``). Extracting this collapses what used to be two + * byte-for-byte identical critical sections so any future change to the + * expiry / cleanup semantics happens in one place. + * + * Returns `{}` (no buffer) on every "no usable token" outcome: + * - store miss + * - empty token + * - expired token (also performs the cleanup write) + * - key-store error (swallowed; callers fall back to base content) + * + * Notes on the cleanup write (preserved from the original implementation): + * - Only fires when an EXPIRED non-empty token was found. Missing tokens + * are NOT wiped because nothing exists to wipe. + * - If the entry carried a `senderTimestamp`, we preserve it via a + * placeholder `{ token: Buffer.alloc(0), senderTimestamp }` so the + * fire-and-forget issuance dedupe in messages-send survives. Otherwise + * we tombstone the entry with `null`. + * - Matches the exact same shape messages-send writes for issuance + * placeholders, so we never accidentally widen the wipe to clear a + * legitimate placeholder. + */ +async function resolveTcTokenForJid({ authState, jid, - baseContent = [], getLIDForPN -}: TcTokenParams): Promise { +}: Pick): Promise { try { const storageJid = getLIDForPN ? await resolveTcTokenJid(jid, getLIDForPN) : jid const tcTokenData = await authState.keys.get('tctoken', [storageJid]) @@ -114,9 +150,6 @@ export async function buildTcTokenFromJid({ const tcTokenBuffer = entry?.token if (!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) { - // Opportunistic cleanup: drop the expired token but preserve senderTimestamp so the - // fire-and-forget issuance dedupe survives — same shape used in messages-send, so this - // path no longer wipes that placeholder (only clears a real, non-empty expired token). if (tcTokenBuffer?.length) { const cleared = entry?.senderTimestamp !== undefined @@ -125,19 +158,61 @@ export async function buildTcTokenFromJid({ await authState.keys.set({ tctoken: { [storageJid]: cleared } }) } - return baseContent.length > 0 ? baseContent : undefined + return {} } - baseContent.push({ - tag: 'tctoken', - attrs: {}, - content: tcTokenBuffer - }) + return { buffer: tcTokenBuffer } + } catch { + return {} + } +} - return baseContent - } catch (error) { +/** + * Legacy sibling-array shape. Used by `presenceSubscribe` where the tctoken + * is the only content of a `` (so the "sibling" framing is moot — + * there's nothing to sibling against). Kept on the legacy + * `buildTcTokenFromJid` + `getLIDForPN` resolver pair for behavioral + * stability. + * + * Returns `baseContent` (mutated in place with the `` appended) + * when a token exists, or `baseContent | undefined` otherwise — same exact + * contract as before the helper extraction. + */ +export async function buildTcTokenFromJid({ + authState, + jid, + baseContent = [], + getLIDForPN +}: TcTokenParams): Promise { + const { buffer } = await resolveTcTokenForJid({ authState, jid, getLIDForPN }) + + if (!buffer) { return baseContent.length > 0 ? baseContent : undefined } + + baseContent.push({ tag: 'tctoken', attrs: {}, content: buffer }) + return baseContent +} + +/** + * Build a standalone BinaryNode (no container, no sibling array). + * + * Use this when the caller needs the tctoken as a CHILD of another stanza node + * — e.g. nested inside for `w:profile:picture` queries (port of + * upstream PR #2614 / matches WA Web's `WASmaxOutProfilePictureTCTokenMixin` + * + whatsmeow's `pictureContent`). + * + * Returns the node when a valid (non-expired, non-empty) tctoken exists for + * the resolved storage JID, or `undefined` otherwise. + */ +export async function buildTcTokenNode({ + authState, + jid, + getLIDForPN +}: Omit): Promise { + const { buffer } = await resolveTcTokenForJid({ authState, jid, getLIDForPN }) + + return buffer ? { tag: 'tctoken', attrs: {}, content: buffer } : undefined } type StoreTcTokensParams = { diff --git a/src/__tests__/Utils/tc-token.test.ts b/src/__tests__/Utils/tc-token.test.ts index 8c53ea1f500..ca62a46f6b7 100644 --- a/src/__tests__/Utils/tc-token.test.ts +++ b/src/__tests__/Utils/tc-token.test.ts @@ -1,7 +1,12 @@ 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 { + buildTcTokenFromJid, + buildTcTokenNode, + isTcTokenExpired, + shouldSendNewTcToken +} from '../../Utils/tc-token-utils' import type { BinaryNode } from '../../WABinary' /** 7 days in seconds — matches WA Web tctoken_duration */ @@ -279,6 +284,81 @@ describe('buildTcTokenFromJid', () => { }) }) +// ─── buildTcTokenNode (single-node helper for nested tctoken in ) ─ + +describe('buildTcTokenNode', () => { + const TEST_JID = 'user@s.whatsapp.net' + const VALID_TOKEN = Buffer.from([4, 1, 33, 254, 110]) + const RECENT_TS = String(nowSeconds() - 86400) // 1 day ago + const EXPIRED_TS = String(nowSeconds() - 30 * 86400) // 30 days ago + + let mockKeys: jest.Mocked + + beforeEach(() => { + mockKeys = createMockKeys() + }) + + it('returns a single tctoken node for valid non-expired token', async () => { + // @ts-ignore + mockKeys.get.mockResolvedValue({ [TEST_JID]: { token: VALID_TOKEN, timestamp: RECENT_TS } }) + + const result = await buildTcTokenNode({ authState: { keys: mockKeys }, jid: TEST_JID }) + + expect(result).toBeDefined() + expect(result!.tag).toBe('tctoken') + expect(result!.attrs).toEqual({}) + expect(result!.content).toBe(VALID_TOKEN) + }) + + it('returns undefined when no token exists', async () => { + // @ts-ignore + mockKeys.get.mockResolvedValue({}) + + const result = await buildTcTokenNode({ authState: { keys: mockKeys }, jid: TEST_JID }) + + expect(result).toBeUndefined() + }) + + it('returns undefined for expired token + opportunistically wipes it', async () => { + // @ts-ignore + mockKeys.get.mockResolvedValue({ [TEST_JID]: { token: VALID_TOKEN, timestamp: EXPIRED_TS } }) + + const result = await buildTcTokenNode({ authState: { keys: mockKeys }, jid: TEST_JID }) + + expect(result).toBeUndefined() + expect(mockKeys.set).toHaveBeenCalledWith({ tctoken: { [TEST_JID]: null } }) + }) + + it('does NOT wipe when token is simply missing', async () => { + // @ts-ignore + mockKeys.get.mockResolvedValue({}) + + await buildTcTokenNode({ authState: { keys: mockKeys }, jid: TEST_JID }) + + expect(mockKeys.set).not.toHaveBeenCalled() + }) + + it('returns undefined and swallows on key store error', async () => { + // @ts-ignore + mockKeys.get.mockRejectedValueOnce(new Error('database error')) + + const result = await buildTcTokenNode({ authState: { keys: mockKeys }, jid: TEST_JID }) + + expect(result).toBeUndefined() + }) + + it('does NOT mutate any baseContent (no caller-passed array exists)', async () => { + // Smoke: signature has no baseContent param. Just confirming the + // function is purely returning a node, not pushing to anything. + // @ts-ignore + mockKeys.get.mockResolvedValue({ [TEST_JID]: { token: VALID_TOKEN, timestamp: RECENT_TS } }) + + const result = await buildTcTokenNode({ authState: { keys: mockKeys }, jid: TEST_JID }) + + expect(result).toEqual({ tag: 'tctoken', attrs: {}, content: VALID_TOKEN }) + }) +}) + // ─── getErrorCodeFromStreamError (stream error parser) ─────────────────── describe('getErrorCodeFromStreamError', () => {