Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/Signal/lid-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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'}`
})
}
Comment thread
rsalcara marked this conversation as resolved.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
/**
* Get LIDs for multiple PNs - Optimized batch operation
*
Expand Down
89 changes: 71 additions & 18 deletions src/Socket/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
} 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,
Expand Down Expand Up @@ -96,6 +96,14 @@
} = 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

Expand Down Expand Up @@ -722,7 +730,7 @@
// collection is done with sync
collectionsToHandle.delete(name)
}
} catch (error: any) {

Check warning on line 733 in src/Socket/chats.ts

View workflow job for this annotation

GitHub Actions / check-lint

Unexpected any. Specify a different type

Check warning on line 733 in src/Socket/chats.ts

View workflow job for this annotation

GitHub Actions / check-lint

Unexpected any. Specify a different type
attemptsMap[name] = (attemptsMap[name] || 0) + 1

const logData = {
Expand Down Expand Up @@ -784,43 +792,88 @@
)

/**
* 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):
*
* <iq xmlns="w:profile:picture" type="get" target="{jid}" to="s.whatsapp.net">
* <picture type="..." query="url" [id] [invite] [persona_id] [common_gid]>
* [<tctoken>...</tctoken>] ← nested CHILD (not sibling)
* </picture>
* </iq>
*/
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 <picture> 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 <picture>. 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
)
Expand Down Expand Up @@ -1640,8 +1693,8 @@
// and the entries don't pin the old socket via closure. NEVER touch a consumer-provided
// cache — they reuse it across reconnects intentionally (CTWA recovery continuity).
if (ownsPlaceholderResendCache) {
placeholderResendCache.close?.()

Check warning on line 1696 in src/Socket/chats.ts

View workflow job for this annotation

GitHub Actions / check-lint

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 1696 in src/Socket/chats.ts

View workflow job for this annotation

GitHub Actions / check-lint

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
placeholderResendCache.flushAll?.()

Check warning on line 1697 in src/Socket/chats.ts

View workflow job for this annotation

GitHub Actions / check-lint

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 1697 in src/Socket/chats.ts

View workflow job for this annotation

GitHub Actions / check-lint

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
// Reset the back-assignment so a reconnect using the same config object doesn't pick
// our just-closed cache up as "consumer-provided" (ownership flip-flop) — that would
// leave the new socket using a timer-stopped cache where entries accumulate forever
Expand Down
103 changes: 89 additions & 14 deletions src/Utils/tc-token-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,22 +101,55 @@ type TcTokenParams = {
getLIDForPN?: (pn: string) => Promise<string | null>
}

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
* `<picture>`). 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<BinaryNode[] | undefined> {
}: Pick<TcTokenParams, 'authState' | 'jid' | 'getLIDForPN'>): Promise<ResolvedTcToken> {
try {
const storageJid = getLIDForPN ? await resolveTcTokenJid(jid, getLIDForPN) : jid
const tcTokenData = await authState.keys.get('tctoken', [storageJid])
const entry = tcTokenData?.[storageJid]
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
Expand All @@ -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 `<presence>` (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 `<tctoken>` 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<BinaryNode[] | undefined> {
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 <tctoken> 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 <picture> 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({
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
authState,
jid,
getLIDForPN
}: Omit<TcTokenParams, 'baseContent'>): Promise<BinaryNode | undefined> {
const { buffer } = await resolveTcTokenForJid({ authState, jid, getLIDForPN })

return buffer ? { tag: 'tctoken', attrs: {}, content: buffer } : undefined
}

type StoreTcTokensParams = {
Expand Down
Loading
Loading