Skip to content

Commit 82d579d

Browse files
authored
client: add cstoken support (#1102)
1 parent 7514259 commit 82d579d

9 files changed

Lines changed: 172 additions & 1 deletion

File tree

appstate.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,19 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa
235235
}
236236
logEvt.Msg("Received app state mutation")
237237

238+
if len(mutation.Index) == 1 && mutation.Index[0] == appstate.IndexNCTSaltSync {
239+
var err error
240+
if mutation.Operation == waServerSync.SyncdMutation_SET {
241+
err = cli.storeNCTSalt(ctx, mutation.Action.GetNctSaltSyncAction().GetSalt())
242+
} else if mutation.Operation == waServerSync.SyncdMutation_REMOVE {
243+
err = cli.clearNCTSalt(ctx)
244+
}
245+
if err != nil {
246+
cli.Log.Warnf("Failed to update NCT salt from app state mutation: %v", err)
247+
}
248+
return
249+
}
250+
238251
if mutation.Operation != waServerSync.SyncdMutation_SET {
239252
return
240253
}

cstoken.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 Tulir Asokan
2+
//
3+
// This Source Code Form is subject to the terms of the Mozilla Public
4+
// License, v. 2.0. If a copy of the MPL was not distributed with this
5+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
6+
7+
package whatsmeow
8+
9+
import (
10+
"context"
11+
"crypto/hmac"
12+
"crypto/sha256"
13+
14+
"go.mau.fi/whatsmeow/types"
15+
)
16+
17+
func shouldSendCsToken(jid types.JID) bool {
18+
jid = jid.ToNonAD()
19+
return (jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer) &&
20+
jid.User != types.PSAJID.User &&
21+
!jid.IsBot()
22+
}
23+
24+
// derives a cstoken for the given JID using HMAC-SHA256(nctSalt, recipientLID).
25+
func (cli *Client) generateCsToken(ctx context.Context, jid types.JID) []byte {
26+
if !shouldSendCsToken(jid) {
27+
return nil
28+
}
29+
if cli.Store == nil || cli.Store.NCTSalt == nil {
30+
return nil
31+
}
32+
salt, err := cli.Store.NCTSalt.GetNCTSalt(ctx)
33+
if err != nil {
34+
cli.Log.Debugf("Failed to load NCT salt for cstoken: %v", err)
35+
return nil
36+
}
37+
if len(salt) == 0 {
38+
return nil
39+
}
40+
var recipientLID types.JID
41+
switch jid.Server {
42+
case types.HiddenUserServer:
43+
recipientLID = jid.ToNonAD()
44+
case types.DefaultUserServer:
45+
if cli.Store == nil || cli.Store.LIDs == nil {
46+
return nil
47+
}
48+
pn := jid.ToNonAD()
49+
lid, err := cli.Store.LIDs.GetLIDForPN(ctx, pn)
50+
if err != nil {
51+
cli.Log.Debugf("Failed to resolve LID for cstoken JID %s: %v", pn, err)
52+
return nil
53+
}
54+
if lid.IsEmpty() {
55+
return nil
56+
}
57+
recipientLID = lid.ToNonAD()
58+
default:
59+
return nil
60+
}
61+
62+
if recipientLID.Server != types.HiddenUserServer {
63+
return nil
64+
}
65+
66+
h := hmac.New(sha256.New, salt)
67+
h.Write([]byte(recipientLID.String()))
68+
return h.Sum(nil)
69+
}
70+
71+
func (cli *Client) storeNCTSalt(ctx context.Context, salt []byte) error {
72+
if cli.Store == nil || cli.Store.NCTSalt == nil {
73+
return nil
74+
}
75+
if len(salt) == 0 {
76+
return nil
77+
}
78+
return cli.Store.NCTSalt.PutNCTSalt(ctx, salt)
79+
}
80+
81+
func (cli *Client) clearNCTSalt(ctx context.Context) error {
82+
if cli.Store == nil || cli.Store.NCTSalt == nil {
83+
return nil
84+
}
85+
return cli.Store.NCTSalt.DeleteNCTSalt(ctx)
86+
}

message.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,9 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History
755755
}
756756
cli.Log.Debugf("Received history sync (type %s, chunk %d, progress %d)", historySync.GetSyncType(), historySync.GetChunkOrder(), historySync.GetProgress())
757757
doStorage := func(ctx context.Context) {
758+
if err := cli.storeNCTSalt(ctx, historySync.GetNctSalt()); err != nil {
759+
cli.Log.Warnf("Failed to store NCT salt from history sync: %v", err)
760+
}
758761
if historySync.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME {
759762
cli.handleHistoricalPushNames(ctx, historySync.GetPushnames())
760763
} else if len(historySync.GetConversations()) > 0 {

send.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,11 @@ func (cli *Client) sendDM(
876876
Tag: "tctoken",
877877
Content: tcTokenBytes,
878878
})
879+
} else if csToken := cli.generateCsToken(ctx, to); len(csToken) > 0 {
880+
node.Content = append(node.GetChildren(), waBinary.Node{
881+
Tag: "cstoken",
882+
Content: csToken,
883+
})
879884
}
880885

881886
start = time.Now()

store/noop.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ var NoopDevice = &Device{
3636
ChatSettings: nilStore,
3737
MsgSecrets: nilStore,
3838
PrivacyTokens: nilStore,
39+
NCTSalt: nilStore,
3940
EventBuffer: nilStore,
4041
LIDs: nilStore,
4142
Container: nilStore,
@@ -228,6 +229,18 @@ func (n *NoopStore) GetPrivacyToken(ctx context.Context, user types.JID) (*Priva
228229
return nil, n.Error
229230
}
230231

232+
func (n *NoopStore) PutNCTSalt(ctx context.Context, salt []byte) error {
233+
return n.Error
234+
}
235+
236+
func (n *NoopStore) GetNCTSalt(ctx context.Context) ([]byte, error) {
237+
return nil, n.Error
238+
}
239+
240+
func (n *NoopStore) DeleteNCTSalt(ctx context.Context) error {
241+
return n.Error
242+
}
243+
231244
func (n *NoopStore) DeleteExpiredPrivacyTokens(ctx context.Context, cutoff time.Time) (int64, error) {
232245
return 0, n.Error
233246
}

store/sqlstore/store.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,15 @@ const (
955955
`
956956
)
957957

958+
const (
959+
putNCTSaltQuery = `
960+
INSERT INTO whatsmeow_nct_salt (our_jid, salt) VALUES ($1, $2)
961+
ON CONFLICT (our_jid) DO UPDATE SET salt=excluded.salt
962+
`
963+
getNCTSaltQuery = `SELECT salt FROM whatsmeow_nct_salt WHERE our_jid=$1`
964+
deleteNCTSaltQuery = `DELETE FROM whatsmeow_nct_salt WHERE our_jid=$1`
965+
)
966+
958967
func (s *SQLStore) PutPrivacyTokens(ctx context.Context, tokens ...store.PrivacyToken) error {
959968
args := make([]any, 1+len(tokens)*4)
960969
placeholders := make([]string, len(tokens))
@@ -994,6 +1003,27 @@ func (s *SQLStore) GetPrivacyToken(ctx context.Context, user types.JID) (*store.
9941003
}
9951004
}
9961005

1006+
func (s *SQLStore) PutNCTSalt(ctx context.Context, salt []byte) error {
1007+
_, err := s.db.Exec(ctx, putNCTSaltQuery, s.JID, salt)
1008+
return err
1009+
}
1010+
1011+
func (s *SQLStore) GetNCTSalt(ctx context.Context) ([]byte, error) {
1012+
var salt []byte
1013+
err := s.db.QueryRow(ctx, getNCTSaltQuery, s.JID).Scan(&salt)
1014+
if errors.Is(err, sql.ErrNoRows) {
1015+
return nil, nil
1016+
} else if err != nil {
1017+
return nil, err
1018+
}
1019+
return salt, nil
1020+
}
1021+
1022+
func (s *SQLStore) DeleteNCTSalt(ctx context.Context) error {
1023+
_, err := s.db.Exec(ctx, deleteNCTSaltQuery, s.JID)
1024+
return err
1025+
}
1026+
9971027
func (s *SQLStore) DeleteExpiredPrivacyTokens(ctx context.Context, cutoff time.Time) (int64, error) {
9981028
res, err := s.db.Exec(ctx, deleteExpiredPrivacyTokens, s.JID, cutoff.Unix())
9991029
if err != nil {

store/sqlstore/upgrades/00-latest-schema.sql

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
-- v0 -> v13 (compatible with v8+): Latest schema
1+
-- v0 -> v14 (compatible with v8+): Latest schema
22
CREATE TABLE whatsmeow_device (
33
jid TEXT PRIMARY KEY,
44
lid TEXT,
@@ -144,6 +144,12 @@ CREATE TABLE whatsmeow_privacy_tokens (
144144
CREATE INDEX idx_whatsmeow_privacy_tokens_our_jid_timestamp
145145
ON whatsmeow_privacy_tokens (our_jid, timestamp);
146146

147+
CREATE TABLE whatsmeow_nct_salt (
148+
our_jid TEXT PRIMARY KEY,
149+
salt bytea NOT NULL,
150+
FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE
151+
);
152+
147153
CREATE TABLE whatsmeow_lid_map (
148154
lid TEXT PRIMARY KEY,
149155
pn TEXT UNIQUE NOT NULL
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- v14 (compatible with v8+): Add NCT salt table for cstoken derivation
2+
CREATE TABLE whatsmeow_nct_salt (
3+
our_jid TEXT PRIMARY KEY,
4+
salt bytea NOT NULL,
5+
FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE
6+
);

store/store.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ type PrivacyTokenStore interface {
149149
DeleteExpiredPrivacyTokens(ctx context.Context, cutoff time.Time) (int64, error)
150150
}
151151

152+
type NCTSaltStore interface {
153+
PutNCTSalt(ctx context.Context, salt []byte) error
154+
GetNCTSalt(ctx context.Context) ([]byte, error)
155+
DeleteNCTSalt(ctx context.Context) error
156+
}
157+
152158
type BufferedEvent struct {
153159
Plaintext []byte
154160
InsertTime time.Time
@@ -195,6 +201,7 @@ type AllSessionSpecificStores interface {
195201
ChatSettingsStore
196202
MsgSecretStore
197203
PrivacyTokenStore
204+
NCTSaltStore
198205
EventBuffer
199206
}
200207

@@ -240,6 +247,7 @@ type Device struct {
240247
ChatSettings ChatSettingsStore
241248
MsgSecrets MsgSecretStore
242249
PrivacyTokens PrivacyTokenStore
250+
NCTSalt NCTSaltStore
243251
EventBuffer EventBuffer
244252
LIDs LIDStore
245253
Container DeviceContainer
@@ -298,6 +306,7 @@ func (device *Device) SetAllStores(store AllSessionSpecificStores) {
298306
device.ChatSettings = store
299307
device.MsgSecrets = store
300308
device.PrivacyTokens = store
309+
device.NCTSalt = store
301310
device.EventBuffer = store
302311
}
303312

0 commit comments

Comments
 (0)