Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
dbc4f26
proto: update to v1035920091
tulir Mar 25, 2026
5465030
send: update on-demand history sync parameters
tulir Mar 27, 2026
7e66fe5
store: add new default client payload fields
tulir Mar 27, 2026
02ec817
upload: add media delete method
tulir Mar 27, 2026
b95d922
proto: update to v1037076227
tulir Apr 10, 2026
015cd40
send: match attrs native uses for on demand history sync
tulir Apr 10, 2026
d4ffc1d
appstate: send sync error event more consistently
tulir Apr 14, 2026
3ff20cd
dependencies: update
tulir Apr 16, 2026
415df31
receipt: catch panics in retry receipt goroutine
tulir Apr 21, 2026
a5594bf
retry: add optional semaphore for retry receipts
tulir Apr 21, 2026
5b88861
clientpayload: bump version number to v1037753511
tulir Apr 21, 2026
74a1ddb
message: add extra data to ciphertext hash in buffer
tulir Apr 21, 2026
bc3d5b3
util/cbc: fix ciphertext length check
tulir Apr 21, 2026
75186f8
handshake: check noise certificate validity
tulir Apr 21, 2026
c26a4c5
appstate,handshake,pair: use constant-time byte comparisons
tulir Apr 21, 2026
76c09e2
pair-code: check received ephemeral key length
tulir Apr 21, 2026
74a8496
proto: update to v1038187123
tulir Apr 24, 2026
8528b5e
client: implement full tctoken lifecycle (#1081)
gusquadri Apr 27, 2026
60c0488
client: add option to manually acknowledge history syncs (#1100)
gusquadri Apr 27, 2026
7514259
.github: link to general contributing guidelines
tulir Apr 27, 2026
82d579d
client: add cstoken support (#1102)
gusquadri May 4, 2026
6661da3
proto: update to v1038709472
tulir May 4, 2026
fcbcaad
msgsecret: add more secret encrypted message types
tulir May 4, 2026
b7ea3e4
client: remove temporary migration for own lid mapping
tulir May 4, 2026
5d16908
msgsecret: pick vote encryption sender based on poll sender
tulir May 4, 2026
e46d104
msgsecret: add hack for trying both senders for decryption
tulir May 4, 2026
51dcc5e
send: add edit attribute to pin in chat messages (#1106)
kalix127 May 4, 2026
6dd3d24
download: add method for fetching sticker packs
tulir May 5, 2026
1c97bf4
msgsecret: fix incorrect variable
tulir May 6, 2026
03911c6
proto: update to v1038839325
tulir May 6, 2026
a763037
store/clientpayload: fix SetWAVersion
tulir May 6, 2026
8d5b5f4
store/clientpayload: remove redundant init function
tulir May 6, 2026
6a7198d
types/sticker: use pointer for sticker pack items
tulir May 6, 2026
3b5b4fe
notification: fix handling own device list changes
tulir May 11, 2026
876de1e
pair: update QR code format
tulir May 11, 2026
59cfb6c
pair: update PairClientType to a string
tulir May 11, 2026
81f8702
pair: add macOS pair client constant
tulir May 11, 2026
eb05d94
pair-code: fix platform ID type
tulir May 11, 2026
9b5c655
msgsecret: include params when failing to decrypt
tulir May 13, 2026
c551a40
proto: update to v1039406452
tulir May 13, 2026
4f7fa75
Merge upstream/main: 40 commits through c551a40
Prodigy90 May 13, 2026
c6cca1b
audit: harden fork goroutine spawns per 4-point review lens
Prodigy90 May 13, 2026
783670b
feat: harvest LID mappings from GetUserDevices usync response
Prodigy90 May 15, 2026
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
1 change: 1 addition & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
See <https://docs.mau.fi/bridges/general/contributing.html>
3 changes: 3 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Checklist

* [ ] I have read and followed the contributing guidelines at <https://docs.mau.fi/bridges/general/contributing.html>
16 changes: 15 additions & 1 deletion appstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ func (cli *Client) fetchAppState(ctx context.Context, name appstate.WAPatchName,
hasMore = patches.HasMorePatches
state, err = cli.applyAppStatePatches(ctx, name, state, patches, fullSync, eventsToDispatchPtr)
if err != nil {
cli.dispatchEvent(&events.AppStateSyncError{Name: name, FullSync: fullSync, Error: err})
return nil, err
}
}
Expand Down Expand Up @@ -161,6 +160,8 @@ func (cli *Client) applyAppStatePatches(
if err != nil {
if errors.Is(err, appstate.ErrKeyNotFound) {
go cli.requestMissingAppStateKeys(context.WithoutCancel(ctx), patches)
} else {
cli.dispatchEvent(&events.AppStateSyncError{Name: name, FullSync: fullSync, Error: err})
}
return state, fmt.Errorf("failed to decode app state %s patches: %w", name, err)
}
Expand Down Expand Up @@ -234,6 +235,19 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa
}
logEvt.Msg("Received app state mutation")

if len(mutation.Index) == 1 && mutation.Index[0] == appstate.IndexNCTSaltSync {
var err error
if mutation.Operation == waServerSync.SyncdMutation_SET {
err = cli.storeNCTSalt(ctx, mutation.Action.GetNctSaltSyncAction().GetSalt())
} else if mutation.Operation == waServerSync.SyncdMutation_REMOVE {
err = cli.clearNCTSalt(ctx)
}
if err != nil {
cli.Log.Warnf("Failed to update NCT salt from app state mutation: %v", err)
}
return
}

if mutation.Operation != waServerSync.SyncdMutation_SET {
return
}
Expand Down
13 changes: 7 additions & 6 deletions appstate/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package appstate
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -122,7 +123,7 @@ func (out *patchOutput) RemoveMAC(indexMAC []byte) {
out.RemovedMACs = append(out.RemovedMACs, indexMAC)
// If the mutation was previously added in this patch, remove it from AddedMACs
out.AddedMACs = slices.DeleteFunc(out.AddedMACs, func(mac store.AppStateMutationMAC) bool {
return bytes.Equal(mac.IndexMAC, indexMAC)
return hmac.Equal(mac.IndexMAC, indexMAC)
})
}

Expand All @@ -149,7 +150,7 @@ func (proc *Processor) decodeMutation(
content, valueMAC = content[:len(content)-32], content[len(content)-32:]
if validateMACs {
expectedValueMAC := generateContentMAC(mutation.GetOperation(), content, keyID, keys.ValueMAC)
if !bytes.Equal(expectedValueMAC, valueMAC) {
if !hmac.Equal(expectedValueMAC, valueMAC) {
err = fmt.Errorf("failed to verify mutation #%d: %w", i+1, ErrMismatchingContentMAC)
return
}
Expand All @@ -169,7 +170,7 @@ func (proc *Processor) decodeMutation(
indexMAC = mutation.GetRecord().GetIndex().GetBlob()
if validateMACs {
expectedIndexMAC := concatAndHMAC(sha256.New, keys.Index, syncAction.Index)
if !bytes.Equal(expectedIndexMAC, indexMAC) {
if !hmac.Equal(expectedIndexMAC, indexMAC) {
err = fmt.Errorf("failed to verify mutation #%d: %w", i+1, ErrMismatchingIndexMAC)
return
}
Expand Down Expand Up @@ -248,7 +249,7 @@ func (proc *Processor) validateSnapshotMAC(ctx context.Context, name WAPatchName
return
}
snapshotMAC := currentState.generateSnapshotMAC(name, keys.SnapshotMAC)
if !bytes.Equal(snapshotMAC, expectedSnapshotMAC) {
if !hmac.Equal(snapshotMAC, expectedSnapshotMAC) {
err = fmt.Errorf("failed to verify patch v%d: %w", currentState.Version, ErrMismatchingLTHash)
}
return
Expand Down Expand Up @@ -321,7 +322,7 @@ func (proc *Processor) validatePatch(
newState.Version = version
warn, err = newState.updateHash(patch.GetMutations(), func(indexMAC []byte, maxIndex int) ([]byte, error) {
for i := maxIndex - 1; i >= 0; i-- {
if bytes.Equal(patch.Mutations[i].GetRecord().GetIndex().GetBlob(), indexMAC) {
if hmac.Equal(patch.Mutations[i].GetRecord().GetIndex().GetBlob(), indexMAC) {
if patch.Mutations[i].GetOperation() == waServerSync.SyncdMutation_SET {
value := patch.Mutations[i].GetRecord().GetValue().GetBlob()
return value[len(value)-32:], nil
Expand All @@ -345,7 +346,7 @@ func (proc *Processor) validatePatch(
return
}
patchMAC := generatePatchMAC(patch, patchName, keys.PatchMAC, patch.GetVersion().GetVersion())
if !bytes.Equal(patchMAC, patch.GetPatchMAC()) {
if !hmac.Equal(patchMAC, patch.GetPatchMAC()) {
err = fmt.Errorf("failed to verify patch v%d: %w", version, ErrMismatchingPatchMAC)
return
}
Expand Down
56 changes: 29 additions & 27 deletions appstate/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,33 +38,34 @@ var AllPatchNames = [...]WAPatchName{WAPatchCriticalBlock, WAPatchCriticalUnbloc

// Constants for the regular_low app state indexes.
const (
IndexPin = "pin_v1"
IndexRecentEmojiWeightsAction = "recent_emoji_weights_action"
IndexArchive = "archive"
IndexSentinel = "sentinel"
IndexMarkChatAsRead = "markChatAsRead"
IndexSettingUnarchiveChats = "setting_unarchiveChats"
IndexAndroidUnsupportedActions = "android_unsupported_actions"
IndexTimeFormat = "time_format"
IndexNux = "nux"
IndexPrimaryVersion = "primary_version"
IndexFavoriteSticker = "favoriteSticker"
IndexRemoveRecentSticker = "removeRecentSticker"
IndexBotWelcomeRequest = "bot_welcome_request"
IndexPaymentInfo = "payment_info"
IndexCustomPaymentMethods = "custom_payment_methods"
IndexLock = "lock"
IndexSettingChatLock = "setting_chatLock"
IndexDeviceCapabilities = "device_capabilities"
IndexNoteEdit = "note_edit"
IndexMerchantPaymentPartner = "merchant_payment_partner"
IndexPaymentTOS = "payment_tos"
IndexAIThreadRename = "ai_thread_rename"
IndexInteractiveMessageAction = "interactive_message_action"
IndexSettingsSync = "settings_sync"
IndexOutContact = "out_contact"
IndexCustomerData = "customer_data"
IndexThreadPin = "thread_pin"
IndexPin = "pin_v1"
IndexRecentEmojiWeightsAction = "recent_emoji_weights_action"
IndexArchive = "archive"
IndexSentinel = "sentinel"
IndexMarkChatAsRead = "markChatAsRead"
IndexSettingUnarchiveChats = "setting_unarchiveChats"
IndexAndroidUnsupportedActions = "android_unsupported_actions"
IndexTimeFormat = "time_format"
IndexNux = "nux"
IndexPrimaryVersion = "primary_version"
IndexFavoriteSticker = "favoriteSticker"
IndexRemoveRecentSticker = "removeRecentSticker"
IndexBotWelcomeRequest = "bot_welcome_request"
IndexPaymentInfo = "payment_info"
IndexCustomPaymentMethods = "custom_payment_methods"
IndexLock = "lock"
IndexSettingChatLock = "setting_chatLock"
IndexDeviceCapabilities = "device_capabilities"
IndexNoteEdit = "note_edit"
IndexMerchantPaymentPartner = "merchant_payment_partner"
IndexPaymentTOS = "payment_tos"
IndexAIThreadRename = "ai_thread_rename"
IndexInteractiveMessageAction = "interactive_message_action"
IndexSettingsSync = "settings_sync"
IndexOutContact = "out_contact"
IndexCustomerData = "customer_data"
IndexThreadPin = "thread_pin"
IndexSettingAutoOrganizeBusinessChat = "setting_autoOrganizeBusinessChat"
)

// Constants for the regular app state indexes.
Expand Down Expand Up @@ -120,6 +121,7 @@ const (
IndexPrivateProcessingSetting = "private_processing_setting"
IndexAIThreadDelete = "ai_thread_delete"
IndexNCTSaltSync = "nct_salt_sync"
IndexBizAISettingsNudgeAction = "biz_ai_settings_nudge_action"
)

// Constants for the critical_unblock_low app state indexes.
Expand Down
45 changes: 41 additions & 4 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"golang.org/x/net/proxy"
"golang.org/x/sync/semaphore"

"go.mau.fi/whatsmeow/appstate"
waBinary "go.mau.fi/whatsmeow/binary"
Expand Down Expand Up @@ -99,9 +100,10 @@ type Client struct {
appStateProc *appstate.Processor
appStateSyncLock sync.Mutex

historySyncNotifications chan *waE2E.HistorySyncNotification
historySyncHandlerStarted atomic.Bool
ManualHistorySyncDownload bool
historySyncNotifications chan *waE2E.HistorySyncNotification
historySyncHandlerStarted atomic.Bool
ManualHistorySyncDownload bool
DisableManualHistorySyncReceipt bool

uploadPreKeysLock sync.Mutex
lastPreKeyUpload time.Time
Expand All @@ -119,6 +121,7 @@ type Client struct {

messageRetries map[string]int
messageRetriesLock sync.Mutex
retrySema *semaphore.Weighted

incomingRetryRequestCounter map[incomingRetryKey]int
incomingRetryRequestCounterLock sync.Mutex
Expand All @@ -128,6 +131,12 @@ type Client struct {

messageSendLock sync.Mutex

tcTokenSenderTS map[types.JID]time.Time
tcTokenSenderTSLock sync.Mutex
lastTCTokenSenderTSCleanup time.Time
tcTokenDBPruneLock sync.Mutex
lastTCTokenDBPrune time.Time

privacySettingsCache atomic.Value

groupCache map[types.JID]*groupMetaCache
Expand Down Expand Up @@ -159,7 +168,12 @@ type Client struct {
// from the usync query (meaning they're not on WhatsApp). The callback receives the JIDs
// that had no devices. This allows the application to passively learn which contacts are
// inactive without making separate IsOnWhatsApp calls.
OnNoDeviceContacts func(jids []types.JID)
//
// The callback runs in a new goroutine. Panics are recovered and logged with a stack trace
// so application code panics don't crash the worker. Use SetMaxParallelNoDeviceContactsCallbacks
// to bound concurrent invocations under heavy GetUserDevices load.
OnNoDeviceContacts func(jids []types.JID)
noDeviceContactsSema *semaphore.Weighted

// PrePairCallback is called before pairing is completed. If it returns false, the pairing will be cancelled and
// the client will disconnect.
Expand All @@ -168,6 +182,7 @@ type Client struct {
// GetClientPayload is called to get the client payload for connecting to the server.
// This should NOT be used for WhatsApp (to change the OS name, update fields in store.BaseClientPayload directly).
GetClientPayload func() *waWa6.ClientPayload
QRClientType PairClientType

// Should untrusted identity errors be handled automatically? If true, the stored identity and existing signal
// sessions will be removed on untrusted identity errors, and an events.IdentityChange will be dispatched.
Expand Down Expand Up @@ -262,6 +277,7 @@ func NewClient(deviceStore *store.Device, log waLog.Logger) *Client {

historySyncNotifications: make(chan *waE2E.HistorySyncNotification, 32),

tcTokenSenderTS: make(map[types.JID]time.Time),
groupCache: make(map[types.JID]*groupMetaCache),
userDevicesCache: make(map[types.JID]deviceCache),

Expand Down Expand Up @@ -409,6 +425,27 @@ func (cli *Client) SetPreLoginHTTPClient(h *http.Client) {
cli.preLoginHTTP = h
}

// SetMaxParallelRetryReceiptHandling sets how many retry receipts can be handled in parallel.
// Defaults to unlimited. This should only be set before connecting, changing it afterwards can cause data races.
func (cli *Client) SetMaxParallelRetryReceiptHandling(n int64) {
if n <= 0 {
cli.retrySema = nil
} else {
cli.retrySema = semaphore.NewWeighted(n)
}
}

// SetMaxParallelNoDeviceContactsCallbacks sets how many OnNoDeviceContacts callback goroutines
// can run in parallel. Defaults to unlimited. This should only be set before connecting,
// changing it afterwards can cause data races.
func (cli *Client) SetMaxParallelNoDeviceContactsCallbacks(n int64) {
if n <= 0 {
cli.noDeviceContactsSema = nil
} else {
cli.noDeviceContactsSema = semaphore.NewWeighted(n)
}
}

func (cli *Client) getSocketWaitChan() <-chan struct{} {
cli.socketLock.RLock()
ch := cli.socketWait
Expand Down
5 changes: 2 additions & 3 deletions connectionevents.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,9 @@ func (cli *Client) handleConnectSuccess(ctx context.Context, node *waBinary.Node
} else {
cli.Log.Infof("Updated LID to %s", cli.Store.LID)
}
cli.StoreLIDPNMapping(ctx, cli.Store.GetLID(), cli.Store.GetJID())
}
// Some users are missing their own LID-PN mapping even though it's already in the device table,
// so do this unconditionally for a few months to ensure everyone gets the row.
cli.StoreLIDPNMapping(ctx, cli.Store.GetLID(), cli.Store.GetJID())
cli.deleteExpiredPrivacyTokens()
go func() {
if dbCount, err := cli.Store.PreKeys.UploadedPreKeyCount(ctx); err != nil {
cli.Log.Errorf("Failed to get number of prekeys in database: %v", err)
Expand Down
86 changes: 86 additions & 0 deletions cstoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package whatsmeow

import (
"context"
"crypto/hmac"
"crypto/sha256"

"go.mau.fi/whatsmeow/types"
)

func shouldSendCsToken(jid types.JID) bool {
jid = jid.ToNonAD()
return (jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer) &&
jid.User != types.PSAJID.User &&
!jid.IsBot()
}

// derives a cstoken for the given JID using HMAC-SHA256(nctSalt, recipientLID).
func (cli *Client) generateCsToken(ctx context.Context, jid types.JID) []byte {
if !shouldSendCsToken(jid) {
return nil
}
if cli.Store == nil || cli.Store.NCTSalt == nil {
return nil
}
salt, err := cli.Store.NCTSalt.GetNCTSalt(ctx)
if err != nil {
cli.Log.Debugf("Failed to load NCT salt for cstoken: %v", err)
return nil
}
if len(salt) == 0 {
return nil
}
var recipientLID types.JID
switch jid.Server {
case types.HiddenUserServer:
recipientLID = jid.ToNonAD()
case types.DefaultUserServer:
if cli.Store == nil || cli.Store.LIDs == nil {
return nil
}
pn := jid.ToNonAD()
lid, err := cli.Store.LIDs.GetLIDForPN(ctx, pn)
if err != nil {
cli.Log.Debugf("Failed to resolve LID for cstoken JID %s: %v", pn, err)
return nil
}
if lid.IsEmpty() {
return nil
}
recipientLID = lid.ToNonAD()
default:
return nil
}

if recipientLID.Server != types.HiddenUserServer {
return nil
}

h := hmac.New(sha256.New, salt)
h.Write([]byte(recipientLID.String()))
return h.Sum(nil)
}

func (cli *Client) storeNCTSalt(ctx context.Context, salt []byte) error {
if cli.Store == nil || cli.Store.NCTSalt == nil {
return nil
}
if len(salt) == 0 {
return nil
}
return cli.Store.NCTSalt.PutNCTSalt(ctx, salt)
}

func (cli *Client) clearNCTSalt(ctx context.Context) error {
if cli.Store == nil || cli.Store.NCTSalt == nil {
return nil
}
return cli.Store.NCTSalt.DeleteNCTSalt(ctx)
}
Loading