• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / tg-spam / 24966212061

26 Apr 2026 08:23PM UTC coverage: 83.078% (+0.1%) from 82.966%
24966212061

Pull #294

github

web-flow
fix: preserve InstanceID across loadConfigFromDB swap; rename collided subtests (#397)

* fix: preserve InstanceID across loadConfigFromDB swap; rename collided subtests

`loadConfigFromDB` does `*settings = *dbSettings` which clobbers the
CLI/env-supplied `InstanceID` with whatever the persisted blob carries.
External orchestrators that write per-instance config blobs without
embedding `instance_id` (e.g. tg-spam-manager) leave it empty, so after
the swap `settings.InstanceID == ""`. Initial load works because the
short-lived store inside loadConfigFromDB was already keyed by the
still-CLI value, but every subsequent `makeDB` call in `activateServer`
opens with `gid=""` — including the runtime SettingsStore.

Symptoms: `POST /config/reload` returns
`500 "no settings found in database: sql: no rows in result set"` from a
clean state. The first UI Save with `saveToDb=true` then writes a fresh
row under `gid=""`, so subsequent reloads succeed against a
manager-orphaned row, leaving manager-side updates and bot-side updates
on different rows.

Fix: snapshot `settings.InstanceID` before the swap and restore it when
the loaded blob's value is empty. A blob that does carry its own
non-empty `InstanceID` (saved by tg-spam itself) is still trusted, so
existing single-binary deployments are unaffected.

Same patch also gives unique names to the three subtests in
`TestDetector_CheckOpenAI` that collided post-master-merge — Go was
running them under `#01` suffixes which made `-run` filtering ambiguous.
Two are byte-identical to their earlier siblings; the third differs
slightly (uses `spam` instead of `viagra` and asserts the LoadStopWords
result), and the new name reflects that.

* add WARN on InstanceID divergence + regression test for empty-blob branch

Address review:

- log a [WARN] when the persisted blob carries a non-empty InstanceID
  that differs from the CLI/env value. Behaviour is unchanged (blob
  still wins) but the divergence shifts the runtime gi... (continued)
Pull Request #294: Implement database configuration support

1290 of 1591 new or added lines in 7 files covered. (81.08%)

10 existing lines in 2 files now uncovered.

8174 of 9839 relevant lines covered (83.08%)

232.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.25
/app/config/crypt.go
1
package config
2

3
import (
4
        "crypto/aes"
5
        "crypto/cipher"
6
        "crypto/rand"
7
        "encoding/base64"
8
        "errors"
9
        "fmt"
10
        "io"
11
        "strings"
12

13
        "golang.org/x/crypto/argon2"
14
)
15

16
// EncryptPrefix is added to encrypted values to identify them
17
const EncryptPrefix = "ENC:"
18

19
// Sensitive field name constants
20
const (
21
        FieldTelegramToken  = "telegram.token"
22
        FieldOpenAIToken    = "openai.token"
23
        FieldGeminiToken    = "gemini.token"
24
        FieldServerAuthHash = "server.auth_hash"
25
)
26

27
// MinKeyLength defines the minimum acceptable length for an encryption key
28
const MinKeyLength = 20
29

30
// Crypter handles encryption and decryption of sensitive fields
31
type Crypter struct {
32
        key []byte
33
}
34

35
// Argon2 parameters for key derivation
36
const (
37
        argon2Time    = 3         // number of iterations (OWASP recommends at least 3)
38
        argon2Memory  = 64 * 1024 // memory usage in KiB (64MB)
39
        argon2Threads = 4         // number of threads
40
        argon2KeyLen  = 32        // output key length (for AES-256)
41
)
42

43
// NewCrypter creates a new encryption manager with the given key and instance ID
44
func NewCrypter(masterKey, instanceID string) (*Crypter, error) {
18✔
45
        if masterKey == "" {
19✔
46
                return nil, errors.New("empty master key")
1✔
47
        }
1✔
48

49
        if len(masterKey) < MinKeyLength {
18✔
50
                return nil, fmt.Errorf("encryption key too short, minimum length is %d characters (use a high-entropy random value)",
1✔
51
                        MinKeyLength)
1✔
52
        }
1✔
53

54
        if instanceID == "" {
17✔
55
                return nil, errors.New("empty instance ID")
1✔
56
        }
1✔
57

58
        // create a salt based on instance ID for consistency across restarts
59
        // this ensures different instances use different keys even with the same master key
60
        salt := []byte("tg-spam-config-encryption-salt-" + instanceID)
15✔
61

15✔
62
        // derive a proper cryptographic key using Argon2id
15✔
63
        key := argon2.IDKey(
15✔
64
                []byte(masterKey),
15✔
65
                salt,
15✔
66
                argon2Time,
15✔
67
                argon2Memory,
15✔
68
                argon2Threads,
15✔
69
                argon2KeyLen,
15✔
70
        )
15✔
71

15✔
72
        return &Crypter{key: key}, nil
15✔
73
}
74

75
// Encrypt encrypts a string value
76
func (c *Crypter) Encrypt(plaintext string) (string, error) {
21✔
77
        if plaintext == "" {
22✔
78
                return "", nil
1✔
79
        }
1✔
80

81
        // create a new AES cipher block
82
        block, err := aes.NewCipher(c.key)
20✔
83
        if err != nil {
20✔
NEW
84
                return "", fmt.Errorf("failed to create cipher: %w", err)
×
NEW
85
        }
×
86

87
        // create the GCM mode with the default nonce size
88
        gcm, err := cipher.NewGCM(block)
20✔
89
        if err != nil {
20✔
NEW
90
                return "", fmt.Errorf("failed to create GCM: %w", err)
×
NEW
91
        }
×
92

93
        // create a nonce
94
        nonce := make([]byte, gcm.NonceSize())
20✔
95
        if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
20✔
NEW
96
                return "", fmt.Errorf("failed to generate nonce: %w", err)
×
NEW
97
        }
×
98

99
        // encrypt and append the nonce
100
        ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
20✔
101

20✔
102
        // encode to base64 and add prefix
20✔
103
        return EncryptPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
20✔
104
}
105

106
// Decrypt decrypts a string value
107
func (c *Crypter) Decrypt(ciphertext string) (string, error) {
25✔
108
        // if not encrypted or empty, return as is
25✔
109
        if ciphertext == "" || !strings.HasPrefix(ciphertext, EncryptPrefix) {
26✔
110
                return ciphertext, nil
1✔
111
        }
1✔
112

113
        // remove prefix and decode from base64
114
        data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(ciphertext, EncryptPrefix))
24✔
115
        if err != nil {
26✔
116
                return "", fmt.Errorf("failed to decode base64 data: %w", err)
2✔
117
        }
2✔
118

119
        // create a new AES cipher block
120
        block, err := aes.NewCipher(c.key)
22✔
121
        if err != nil {
22✔
NEW
122
                return "", fmt.Errorf("failed to create cipher for decryption: %w", err)
×
NEW
123
        }
×
124

125
        // create the GCM mode
126
        gcm, err := cipher.NewGCM(block)
22✔
127
        if err != nil {
22✔
NEW
128
                return "", fmt.Errorf("failed to create GCM for decryption: %w", err)
×
NEW
129
        }
×
130

131
        // the nonce is at the beginning of the ciphertext
132
        nonceSize := gcm.NonceSize()
22✔
133
        if len(data) < nonceSize {
22✔
NEW
134
                return "", errors.New("ciphertext too short")
×
NEW
135
        }
×
136

137
        nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
22✔
138
        plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
22✔
139
        if err != nil {
24✔
140
                return "", fmt.Errorf("failed to decrypt data: %w", err)
2✔
141
        }
2✔
142

143
        return string(plaintext), nil
20✔
144
}
145

146
// IsEncrypted checks if a value is encrypted
147
func IsEncrypted(value string) bool {
75✔
148
        return strings.HasPrefix(value, EncryptPrefix)
75✔
149
}
75✔
150

151
// sensitiveFieldAccessors maps sensitive field names to accessors that return
152
// a pointer to the backing string on a Settings instance plus a human-readable
153
// label used in error messages. Adding a new sensitive field is a single-place
154
// change here; defaultSensitiveFields derives its list from these keys.
155
var sensitiveFieldAccessors = map[string]struct {
156
        label string
157
        get   func(*Settings) *string
158
}{
159
        FieldTelegramToken:  {"Telegram token", func(s *Settings) *string { return &s.Telegram.Token }},
17✔
160
        FieldOpenAIToken:    {"OpenAI token", func(s *Settings) *string { return &s.OpenAI.Token }},
13✔
161
        FieldGeminiToken:    {"Gemini token", func(s *Settings) *string { return &s.Gemini.Token }},
13✔
162
        FieldServerAuthHash: {"Server auth hash", func(s *Settings) *string { return &s.Server.AuthHash }},
13✔
163
}
164

165
// EncryptSensitiveFields encrypts sensitive fields in a Settings object
166
// It can encrypt default fields or custom fields specified in sensitiveFields.
167
// Returns an error on the first encryption failure or unknown field.
168
func (c *Crypter) EncryptSensitiveFields(settings *Settings, sensitiveFields ...string) error {
9✔
169
        if settings == nil {
9✔
NEW
170
                return nil
×
NEW
171
        }
×
172

173
        fieldsToEncrypt := sensitiveFields
9✔
174
        if len(fieldsToEncrypt) == 0 {
11✔
175
                fieldsToEncrypt = defaultSensitiveFields()
2✔
176
        }
2✔
177

178
        for _, field := range fieldsToEncrypt {
36✔
179
                accessor, ok := sensitiveFieldAccessors[field]
27✔
180
                if !ok {
28✔
181
                        return fmt.Errorf("unknown sensitive field: %s", field)
1✔
182
                }
1✔
183
                target := accessor.get(settings)
26✔
184
                if *target == "" || IsEncrypted(*target) {
37✔
185
                        continue
11✔
186
                }
187
                encrypted, err := c.Encrypt(*target)
15✔
188
                if err != nil {
15✔
NEW
189
                        return fmt.Errorf("failed to encrypt %s: %w", accessor.label, err)
×
NEW
190
                }
×
191
                *target = encrypted
15✔
192
        }
193

194
        return nil
8✔
195
}
196

197
// DecryptSensitiveFields decrypts sensitive fields in a Settings object.
198
// It can decrypt default fields or custom fields specified in sensitiveFields.
199
// Returns an error on the first decryption failure or unknown field; symmetric
200
// with EncryptSensitiveFields.
201
func (c *Crypter) DecryptSensitiveFields(settings *Settings, sensitiveFields ...string) error {
10✔
202
        if settings == nil {
10✔
NEW
203
                return nil
×
NEW
204
        }
×
205

206
        fieldsToDecrypt := sensitiveFields
10✔
207
        if len(fieldsToDecrypt) == 0 {
13✔
208
                fieldsToDecrypt = defaultSensitiveFields()
3✔
209
        }
3✔
210

211
        for _, field := range fieldsToDecrypt {
41✔
212
                accessor, ok := sensitiveFieldAccessors[field]
31✔
213
                if !ok {
32✔
214
                        return fmt.Errorf("unknown sensitive field: %s", field)
1✔
215
                }
1✔
216
                target := accessor.get(settings)
30✔
217
                if !IsEncrypted(*target) {
43✔
218
                        continue
13✔
219
                }
220
                decrypted, err := c.Decrypt(*target)
17✔
221
                if err != nil {
18✔
222
                        return fmt.Errorf("failed to decrypt %s: %w", accessor.label, err)
1✔
223
                }
1✔
224
                *target = decrypted
16✔
225
        }
226

227
        return nil
8✔
228
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc