From 472d91de8d7560478b8b3278da02910a372092d9 Mon Sep 17 00:00:00 2001 From: Gustavo Quadri <87215048+gusquadri@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:07:45 -0300 Subject: [PATCH 1/6] feat: cstoken initial support --- appstate.go | 19 ++++++ cstoken.go | 72 ++++++++++++++++++++ message.go | 5 ++ send.go | 5 ++ store/noop.go | 13 ++++ store/sqlstore/store.go | 30 ++++++++ store/sqlstore/upgrades/00-latest-schema.sql | 8 ++- store/sqlstore/upgrades/14-nct-salt.sql | 6 ++ store/store.go | 9 +++ 9 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 cstoken.go create mode 100644 store/sqlstore/upgrades/14-nct-salt.sql diff --git a/appstate.go b/appstate.go index beacc4485..8bebe793c 100644 --- a/appstate.go +++ b/appstate.go @@ -234,6 +234,25 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa } logEvt.Msg("Received app state mutation") + if len(mutation.Index) > 0 && + mutation.Index[0] == appstate.IndexNCTSaltSync && + (mutation.Operation == waServerSync.SyncdMutation_SET || mutation.Operation == waServerSync.SyncdMutation_REMOVE) { + var err error + if mutation.Operation == waServerSync.SyncdMutation_SET { + if salt := mutation.Action.GetNctSaltSyncAction().GetSalt(); len(salt) > 0 { + err = cli.storeNCTSalt(ctx, salt) + } else { + err = cli.clearNCTSalt(ctx) + } + } else { + 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 } diff --git a/cstoken.go b/cstoken.go new file mode 100644 index 000000000..87b4698dc --- /dev/null +++ b/cstoken.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Gus Quadri +// +// 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 + } + recipientLID := jid.ToNonAD() + if recipientLID.Server == types.DefaultUserServer && cli.Store != nil && cli.Store.LIDs != nil { + lid, err := cli.Store.LIDs.GetLIDForPN(ctx, recipientLID) + if err != nil { + cli.Log.Debugf("Failed to resolve LID for cstoken JID %s: %v", recipientLID, err) + return nil + } + if lid.IsEmpty() { + return nil + } + recipientLID = lid.ToNonAD() + } + 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 cli.Store.NCTSalt.DeleteNCTSalt(ctx) + } + return cli.Store.NCTSalt.PutNCTSalt(ctx, append([]byte(nil), salt...)) +} + +func (cli *Client) clearNCTSalt(ctx context.Context) error { + return cli.storeNCTSalt(ctx, nil) +} diff --git a/message.go b/message.go index a2309330b..50700dfaa 100644 --- a/message.go +++ b/message.go @@ -711,6 +711,11 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History } cli.Log.Debugf("Received history sync (type %s, chunk %d, progress %d)", historySync.GetSyncType(), historySync.GetChunkOrder(), historySync.GetProgress()) doStorage := func(ctx context.Context) { + if salt := historySync.GetNctSalt(); len(salt) > 0 { + if err := cli.storeNCTSalt(ctx, salt); err != nil { + cli.Log.Warnf("Failed to store NCT salt from history sync: %v", err) + } + } if historySync.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME { cli.handleHistoricalPushNames(ctx, historySync.GetPushnames()) } else if len(historySync.GetConversations()) > 0 { diff --git a/send.go b/send.go index a278b0252..92344913f 100644 --- a/send.go +++ b/send.go @@ -873,6 +873,11 @@ func (cli *Client) sendDM( Tag: "tctoken", Content: tcToken.Token, }) + } else if csToken := cli.generateCsToken(ctx, to); len(csToken) > 0 { + node.Content = append(node.GetChildren(), waBinary.Node{ + Tag: "cstoken", + Content: csToken, + }) } start = time.Now() diff --git a/store/noop.go b/store/noop.go index d0b99f083..748f47cf4 100644 --- a/store/noop.go +++ b/store/noop.go @@ -36,6 +36,7 @@ var NoopDevice = &Device{ ChatSettings: nilStore, MsgSecrets: nilStore, PrivacyTokens: nilStore, + NCTSalt: nilStore, EventBuffer: nilStore, LIDs: nilStore, Container: nilStore, @@ -228,6 +229,18 @@ func (n *NoopStore) GetPrivacyToken(ctx context.Context, user types.JID) (*Priva return nil, n.Error } +func (n *NoopStore) PutNCTSalt(ctx context.Context, salt []byte) error { + return n.Error +} + +func (n *NoopStore) GetNCTSalt(ctx context.Context) ([]byte, error) { + return nil, n.Error +} + +func (n *NoopStore) DeleteNCTSalt(ctx context.Context) error { + return n.Error +} + func (n *NoopStore) PutDevice(ctx context.Context, store *Device) error { return n.Error } diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 10afd7312..0dedae6de 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -947,6 +947,15 @@ const ( ` ) +const ( + putNCTSaltQuery = ` + INSERT INTO whatsmeow_nct_salt (our_jid, salt) VALUES ($1, $2) + ON CONFLICT (our_jid) DO UPDATE SET salt=excluded.salt + ` + getNCTSaltQuery = `SELECT salt FROM whatsmeow_nct_salt WHERE our_jid=$1` + deleteNCTSaltQuery = `DELETE FROM whatsmeow_nct_salt WHERE our_jid=$1` +) + func (s *SQLStore) PutPrivacyTokens(ctx context.Context, tokens ...store.PrivacyToken) error { args := make([]any, 1+len(tokens)*3) placeholders := make([]string, len(tokens)) @@ -977,6 +986,27 @@ func (s *SQLStore) GetPrivacyToken(ctx context.Context, user types.JID) (*store. } } +func (s *SQLStore) PutNCTSalt(ctx context.Context, salt []byte) error { + _, err := s.db.Exec(ctx, putNCTSaltQuery, s.JID, salt) + return err +} + +func (s *SQLStore) GetNCTSalt(ctx context.Context) ([]byte, error) { + var salt []byte + err := s.db.QueryRow(ctx, getNCTSaltQuery, s.JID).Scan(&salt) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + return salt, nil +} + +func (s *SQLStore) DeleteNCTSalt(ctx context.Context) error { + _, err := s.db.Exec(ctx, deleteNCTSaltQuery, s.JID) + return err +} + const ( getBufferedEventQuery = ` SELECT plaintext, server_timestamp, insert_timestamp FROM whatsmeow_event_buffer WHERE our_jid = $1 AND ciphertext_hash = $2 diff --git a/store/sqlstore/upgrades/00-latest-schema.sql b/store/sqlstore/upgrades/00-latest-schema.sql index adf001316..06f8346a3 100644 --- a/store/sqlstore/upgrades/00-latest-schema.sql +++ b/store/sqlstore/upgrades/00-latest-schema.sql @@ -1,4 +1,4 @@ --- v0 -> v13 (compatible with v8+): Latest schema +-- v0 -> v14 (compatible with v8+): Latest schema CREATE TABLE whatsmeow_device ( jid TEXT PRIMARY KEY, lid TEXT, @@ -144,6 +144,12 @@ CREATE TABLE whatsmeow_privacy_tokens ( CREATE INDEX idx_whatsmeow_privacy_tokens_our_jid_timestamp ON whatsmeow_privacy_tokens (our_jid, timestamp); +CREATE TABLE whatsmeow_nct_salt ( + our_jid TEXT PRIMARY KEY, + salt bytea NOT NULL, + FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE +); + CREATE TABLE whatsmeow_lid_map ( lid TEXT PRIMARY KEY, pn TEXT UNIQUE NOT NULL diff --git a/store/sqlstore/upgrades/14-nct-salt.sql b/store/sqlstore/upgrades/14-nct-salt.sql new file mode 100644 index 000000000..852f62746 --- /dev/null +++ b/store/sqlstore/upgrades/14-nct-salt.sql @@ -0,0 +1,6 @@ +-- v14: Add NCT salt table for cstoken derivation +CREATE TABLE whatsmeow_nct_salt ( + our_jid TEXT PRIMARY KEY, + salt bytea NOT NULL, + FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/store/store.go b/store/store.go index cef81cf1d..25c1453fe 100644 --- a/store/store.go +++ b/store/store.go @@ -147,6 +147,12 @@ type PrivacyTokenStore interface { GetPrivacyToken(ctx context.Context, user types.JID) (*PrivacyToken, error) } +type NCTSaltStore interface { + PutNCTSalt(ctx context.Context, salt []byte) error + GetNCTSalt(ctx context.Context) ([]byte, error) + DeleteNCTSalt(ctx context.Context) error +} + type BufferedEvent struct { Plaintext []byte InsertTime time.Time @@ -193,6 +199,7 @@ type AllSessionSpecificStores interface { ChatSettingsStore MsgSecretStore PrivacyTokenStore + NCTSaltStore EventBuffer } @@ -238,6 +245,7 @@ type Device struct { ChatSettings ChatSettingsStore MsgSecrets MsgSecretStore PrivacyTokens PrivacyTokenStore + NCTSalt NCTSaltStore EventBuffer EventBuffer LIDs LIDStore Container DeviceContainer @@ -296,6 +304,7 @@ func (device *Device) SetAllStores(store AllSessionSpecificStores) { device.ChatSettings = store device.MsgSecrets = store device.PrivacyTokens = store + device.NCTSalt = store device.EventBuffer = store } From 033067008d7dee2f541f533dd7df82628d7afab2 Mon Sep 17 00:00:00 2001 From: Gustavo Quadri <87215048+gusquadri@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:19:47 -0300 Subject: [PATCH 2/6] fix: copyright header --- cstoken.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cstoken.go b/cstoken.go index 87b4698dc..5b6b6d372 100644 --- a/cstoken.go +++ b/cstoken.go @@ -1,4 +1,4 @@ -// Copyright (c) 2026 Gus Quadri +// 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 From 32137e44f4763389b63f38a87b84c001bde1cb72 Mon Sep 17 00:00:00 2001 From: Gustavo Quadri <87215048+gusquadri@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:27:57 -0300 Subject: [PATCH 3/6] fix: jid handling in generatecstoken --- cstoken.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/cstoken.go b/cstoken.go index 5b6b6d372..093148bca 100644 --- a/cstoken.go +++ b/cstoken.go @@ -37,21 +37,32 @@ func (cli *Client) generateCsToken(ctx context.Context, jid types.JID) []byte { if len(salt) == 0 { return nil } - recipientLID := jid.ToNonAD() - if recipientLID.Server == types.DefaultUserServer && cli.Store != nil && cli.Store.LIDs != nil { - lid, err := cli.Store.LIDs.GetLIDForPN(ctx, recipientLID) + 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", recipientLID, err) + 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) From 27eb66f9727bdcdb6ba371e5633099a8a943538d Mon Sep 17 00:00:00 2001 From: Gustavo Quadri <87215048+gusquadri@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:30:03 -0300 Subject: [PATCH 4/6] fix: simplify NCT salt handling in storeNCTSalt and dispatchAppState --- appstate.go | 10 ++-------- cstoken.go | 2 +- store/sqlstore/upgrades/14-nct-salt.sql | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/appstate.go b/appstate.go index 8bebe793c..cb9bdf3a6 100644 --- a/appstate.go +++ b/appstate.go @@ -234,16 +234,10 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa } logEvt.Msg("Received app state mutation") - if len(mutation.Index) > 0 && - mutation.Index[0] == appstate.IndexNCTSaltSync && - (mutation.Operation == waServerSync.SyncdMutation_SET || mutation.Operation == waServerSync.SyncdMutation_REMOVE) { + if len(mutation.Index) == 1 && mutation.Index[0] == appstate.IndexNCTSaltSync { var err error if mutation.Operation == waServerSync.SyncdMutation_SET { - if salt := mutation.Action.GetNctSaltSyncAction().GetSalt(); len(salt) > 0 { - err = cli.storeNCTSalt(ctx, salt) - } else { - err = cli.clearNCTSalt(ctx) - } + err = cli.storeNCTSalt(ctx, mutation.Action.GetNctSaltSyncAction().GetSalt()) } else { err = cli.clearNCTSalt(ctx) } diff --git a/cstoken.go b/cstoken.go index 093148bca..4af678708 100644 --- a/cstoken.go +++ b/cstoken.go @@ -75,7 +75,7 @@ func (cli *Client) storeNCTSalt(ctx context.Context, salt []byte) error { if len(salt) == 0 { return cli.Store.NCTSalt.DeleteNCTSalt(ctx) } - return cli.Store.NCTSalt.PutNCTSalt(ctx, append([]byte(nil), salt...)) + return cli.Store.NCTSalt.PutNCTSalt(ctx, salt) } func (cli *Client) clearNCTSalt(ctx context.Context) error { diff --git a/store/sqlstore/upgrades/14-nct-salt.sql b/store/sqlstore/upgrades/14-nct-salt.sql index 852f62746..ee8475acb 100644 --- a/store/sqlstore/upgrades/14-nct-salt.sql +++ b/store/sqlstore/upgrades/14-nct-salt.sql @@ -1,4 +1,4 @@ --- v14: Add NCT salt table for cstoken derivation +-- v14 (compatible with v8+): Add NCT salt table for cstoken derivation CREATE TABLE whatsmeow_nct_salt ( our_jid TEXT PRIMARY KEY, salt bytea NOT NULL, From 82782d12c5effedf84b6ee9b2851d069e12c7c23 Mon Sep 17 00:00:00 2001 From: Gustavo Quadri <87215048+gusquadri@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:02:10 -0300 Subject: [PATCH 5/6] fix: streamline NCT salt handling in storeNCTSalt and DownloadHistorySync --- cstoken.go | 7 +++++-- message.go | 6 ++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cstoken.go b/cstoken.go index 4af678708..13cfc7b66 100644 --- a/cstoken.go +++ b/cstoken.go @@ -73,11 +73,14 @@ func (cli *Client) storeNCTSalt(ctx context.Context, salt []byte) error { return nil } if len(salt) == 0 { - return cli.Store.NCTSalt.DeleteNCTSalt(ctx) + return nil } return cli.Store.NCTSalt.PutNCTSalt(ctx, salt) } func (cli *Client) clearNCTSalt(ctx context.Context) error { - return cli.storeNCTSalt(ctx, nil) + if cli.Store == nil || cli.Store.NCTSalt == nil { + return nil + } + return cli.Store.NCTSalt.DeleteNCTSalt(ctx) } diff --git a/message.go b/message.go index 9b972aceb..da23c35f9 100644 --- a/message.go +++ b/message.go @@ -755,10 +755,8 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History } cli.Log.Debugf("Received history sync (type %s, chunk %d, progress %d)", historySync.GetSyncType(), historySync.GetChunkOrder(), historySync.GetProgress()) doStorage := func(ctx context.Context) { - if salt := historySync.GetNctSalt(); len(salt) > 0 { - if err := cli.storeNCTSalt(ctx, salt); err != nil { - cli.Log.Warnf("Failed to store NCT salt from history sync: %v", err) - } + if err := cli.storeNCTSalt(ctx, historySync.GetNctSalt()); err != nil { + cli.Log.Warnf("Failed to store NCT salt from history sync: %v", err) } if historySync.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME { cli.handleHistoricalPushNames(ctx, historySync.GetPushnames()) From 82b5241169ae14bfb98a3d9a23e87ef9ca4a87f1 Mon Sep 17 00:00:00 2001 From: Gustavo Quadri <87215048+gusquadri@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:10:25 -0300 Subject: [PATCH 6/6] fix: explicit handle SyncdMutation_REMOVE --- appstate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appstate.go b/appstate.go index eceed9619..c2e2900df 100644 --- a/appstate.go +++ b/appstate.go @@ -239,7 +239,7 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa var err error if mutation.Operation == waServerSync.SyncdMutation_SET { err = cli.storeNCTSalt(ctx, mutation.Action.GetNctSaltSyncAction().GetSalt()) - } else { + } else if mutation.Operation == waServerSync.SyncdMutation_REMOVE { err = cli.clearNCTSalt(ctx) } if err != nil {