Skip to content
Open
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
13 changes: 13 additions & 0 deletions appstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,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
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)
}
3 changes: 3 additions & 0 deletions message.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,9 @@ 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 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())
} else if len(historySync.GetConversations()) > 0 {
Expand Down
5 changes: 5 additions & 0 deletions send.go
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,11 @@ func (cli *Client) sendDM(
Tag: "tctoken",
Content: tcTokenBytes,
})
} else if csToken := cli.generateCsToken(ctx, to); len(csToken) > 0 {
node.Content = append(node.GetChildren(), waBinary.Node{
Tag: "cstoken",
Content: csToken,
})
}

start = time.Now()
Expand Down
13 changes: 13 additions & 0 deletions store/noop.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var NoopDevice = &Device{
ChatSettings: nilStore,
MsgSecrets: nilStore,
PrivacyTokens: nilStore,
NCTSalt: nilStore,
EventBuffer: nilStore,
LIDs: nilStore,
Container: nilStore,
Expand Down Expand Up @@ -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) DeleteExpiredPrivacyTokens(ctx context.Context, cutoff time.Time) (int64, error) {
return 0, n.Error
}
Expand Down
30 changes: 30 additions & 0 deletions store/sqlstore/store.go
Comment thread
gusquadri marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,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)*4)
placeholders := make([]string, len(tokens))
Expand Down Expand Up @@ -994,6 +1003,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
}

func (s *SQLStore) DeleteExpiredPrivacyTokens(ctx context.Context, cutoff time.Time) (int64, error) {
res, err := s.db.Exec(ctx, deleteExpiredPrivacyTokens, s.JID, cutoff.Unix())
if err != nil {
Expand Down
8 changes: 7 additions & 1 deletion store/sqlstore/upgrades/00-latest-schema.sql
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions store/sqlstore/upgrades/14-nct-salt.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- 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,
FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE
);
9 changes: 9 additions & 0 deletions store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ type PrivacyTokenStore interface {
DeleteExpiredPrivacyTokens(ctx context.Context, cutoff time.Time) (int64, 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
Expand Down Expand Up @@ -195,6 +201,7 @@ type AllSessionSpecificStores interface {
ChatSettingsStore
MsgSecretStore
PrivacyTokenStore
NCTSaltStore
EventBuffer
}

Expand Down Expand Up @@ -240,6 +247,7 @@ type Device struct {
ChatSettings ChatSettingsStore
MsgSecrets MsgSecretStore
PrivacyTokens PrivacyTokenStore
NCTSalt NCTSaltStore
EventBuffer EventBuffer
LIDs LIDStore
Container DeviceContainer
Expand Down Expand Up @@ -298,6 +306,7 @@ func (device *Device) SetAllStores(store AllSessionSpecificStores) {
device.ChatSettings = store
device.MsgSecrets = store
device.PrivacyTokens = store
device.NCTSalt = store
device.EventBuffer = store
}

Expand Down
Loading