Files

204 lines
6.3 KiB
Go

// Copyright (c) 2025 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"
"fmt"
"time"
waBinary "go.mau.fi/whatsmeow/binary"
"go.mau.fi/whatsmeow/types"
)
const (
// tcTokenBucketDuration is the duration of a single bucket in seconds (7 days).
// Matches AB prop tctoken_duration.
tcTokenBucketDuration = 604800
// tcTokenNumBuckets is the number of rolling buckets (4 = ~28-day window).
// Matches AB prop tctoken_num_buckets.
tcTokenNumBuckets = 4
tcTokenDBPruneInterval = 24 * time.Hour
)
func currentTCTokenCutoffTimestamp() time.Time {
currentBucket := time.Now().Unix() / tcTokenBucketDuration
cutoffBucket := currentBucket - (tcTokenNumBuckets - 1)
return time.Unix(cutoffBucket*tcTokenBucketDuration, 0)
}
func isTCTokenExpired(timestamp time.Time) bool {
if timestamp.IsZero() {
return true
}
return timestamp.Before(currentTCTokenCutoffTimestamp())
}
// shouldSendNewTCToken returns true when the current bucket is newer than the last issuance bucket.
func shouldSendNewTCToken(senderTimestamp time.Time) bool {
if senderTimestamp.IsZero() {
return true
}
now := time.Now().Unix()
return now/tcTokenBucketDuration > senderTimestamp.Unix()/tcTokenBucketDuration
}
func shouldSendTCTokenInChatAction(jid types.JID) bool {
jid = jid.ToNonAD()
return (jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer) &&
jid.User != types.PSAJID.User &&
!jid.IsBot()
}
func (cli *Client) resolveTCTokenStorageLID(ctx context.Context, jid types.JID) types.JID {
storageJID := jid.ToNonAD()
if storageJID.Server != types.DefaultUserServer {
return storageJID
}
lid, err := cli.Store.LIDs.GetLIDForPN(ctx, storageJID)
if err != nil {
cli.Log.Debugf("Failed to resolve LID for tctoken JID %s: %v", storageJID, err)
return storageJID
}
if lid.IsEmpty() {
return storageJID
}
return lid.ToNonAD()
}
// getTCTokenSenderTS reads the in-memory sender timestamp for a JID.
func (cli *Client) getTCTokenSenderTS(jid types.JID) time.Time {
cli.tcTokenSenderTSLock.Lock()
defer cli.tcTokenSenderTSLock.Unlock()
return cli.tcTokenSenderTS[jid.ToNonAD()]
}
func (cli *Client) validateAndSetTCTokenSenderTS(jid types.JID, storedSenderTimestamp time.Time) bool {
cli.tcTokenSenderTSLock.Lock()
defer cli.tcTokenSenderTSLock.Unlock()
key := jid.ToNonAD()
if _, ok := cli.tcTokenSenderTS[key]; ok {
return true
}
if storedSenderTimestamp.IsZero() || storedSenderTimestamp.Before(currentTCTokenCutoffTimestamp()) {
return false
}
cli.tcTokenSenderTS[key] = storedSenderTimestamp
cli.unlockedCleanupTCTokenSenderTSMap()
return true
}
// setTCTokenSenderTS writes the in-memory sender timestamp for a JID.
func (cli *Client) setTCTokenSenderTS(jid types.JID, ts time.Time) {
cli.tcTokenSenderTSLock.Lock()
defer cli.tcTokenSenderTSLock.Unlock()
cli.tcTokenSenderTS[jid.ToNonAD()] = ts
cli.unlockedCleanupTCTokenSenderTSMap()
}
func (cli *Client) unlockedCleanupTCTokenSenderTSMap() {
if time.Since(cli.lastTCTokenSenderTSCleanup) < tcTokenBucketDuration*time.Second {
return
}
cli.lastTCTokenSenderTSCleanup = time.Now()
cutoffTimestamp := currentTCTokenCutoffTimestamp()
for jid, ts := range cli.tcTokenSenderTS {
if ts.Before(cutoffTimestamp) {
delete(cli.tcTokenSenderTS, jid)
}
}
}
// ensureTCToken returns a stored non-expired tctoken for the given JID, if available.
func (cli *Client) ensureTCToken(ctx context.Context, jid types.JID) (token []byte, err error) {
if cli.getOwnID().IsEmpty() {
return nil, ErrNotLoggedIn
}
cli.deleteExpiredPrivacyTokens()
storageJID := cli.resolveTCTokenStorageLID(ctx, jid)
existing, err := cli.Store.PrivacyTokens.GetPrivacyToken(ctx, storageJID)
if err != nil {
return nil, fmt.Errorf("failed to get privacy token: %w", err)
}
if existing == nil {
return nil, nil
}
cli.validateAndSetTCTokenSenderTS(storageJID, existing.SenderTimestamp)
if len(existing.Token) > 0 && !isTCTokenExpired(existing.Timestamp) {
return existing.Token, nil
}
return nil, nil
}
func (cli *Client) deleteExpiredPrivacyTokens() {
if !cli.tcTokenDBPruneLock.TryLock() {
return
}
if time.Since(cli.lastTCTokenDBPrune) < tcTokenDBPruneInterval {
cli.tcTokenDBPruneLock.Unlock()
return
}
cli.lastTCTokenDBPrune = time.Now()
go func() {
defer cli.tcTokenDBPruneLock.Unlock()
deleted, err := cli.Store.PrivacyTokens.DeleteExpiredPrivacyTokens(cli.BackgroundEventCtx, currentTCTokenCutoffTimestamp())
if err != nil {
cli.Log.Warnf("Failed to remove expired tctokens from DB: %v", err)
} else if deleted > 0 {
cli.Log.Debugf("Removed %d expired tctokens from DB", deleted)
}
}()
}
// Only called when a bucket boundary has been crossed since the last issuance.
func (cli *Client) issuePrivacyTokenAndSave(jid types.JID, senderTimestamp time.Time) {
ctx := cli.BackgroundEventCtx
storageJID := jid.ToNonAD()
_, err := cli.issuePrivacyToken(ctx, storageJID, senderTimestamp)
if err != nil {
cli.Log.Errorf("Failed to issue privacy token for %s: %v", jid, err)
return
}
cli.setTCTokenSenderTS(storageJID, senderTimestamp)
// TODO replace with an UPDATE call instead of get+put
existing, err := cli.Store.PrivacyTokens.GetPrivacyToken(ctx, storageJID)
if err != nil {
cli.Log.Errorf("Failed to load tctoken while persisting sender timestamp for %s: %v", jid, err)
return
}
if existing == nil || len(existing.Token) == 0 {
return
}
existing.SenderTimestamp = senderTimestamp
if err = cli.Store.PrivacyTokens.PutPrivacyTokens(ctx, *existing); err != nil {
cli.Log.Errorf("Failed to persist privacy token sender timestamp for %s: %v", jid, err)
}
}
// issuePrivacyToken sends an IQ to the server to issue a privacy token for the given JID.
func (cli *Client) issuePrivacyToken(ctx context.Context, jid types.JID, timestamp time.Time) (*waBinary.Node, error) {
return cli.sendIQ(ctx, infoQuery{
Namespace: "privacy",
Type: iqSet,
To: types.ServerJID,
Content: []waBinary.Node{{
Tag: "tokens",
Content: []waBinary.Node{{
Tag: "token",
Attrs: waBinary.Attrs{
"jid": jid.ToNonAD(),
"t": fmt.Sprintf("%d", timestamp.Unix()),
"type": "trusted_contact",
},
}},
}},
})
}