config.go raw
1 package config
2
3 import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "os"
10 "strings"
11 "sync"
12
13 "github.com/getAlby/go-nostr/nip19"
14 "github.com/getAlby/hub/constants"
15 "github.com/getAlby/hub/db"
16 "github.com/getAlby/hub/logger"
17 "github.com/sirupsen/logrus"
18 "gorm.io/gorm"
19 "gorm.io/gorm/clause"
20 )
21
22 type config struct {
23 Env *AppConfig
24 db *gorm.DB
25 cache map[string]map[string]string // key -> encryptionKeyHash -> value
26 cacheMutex sync.Mutex
27 jwtSecret string
28 jwtSecretMutex sync.Mutex
29 sessionSecret string
30 sessionSecretMutex sync.Mutex
31 }
32
33 const (
34 unlockPasswordCheck = "THIS STRING SHOULD MATCH IF PASSWORD IS CORRECT"
35 )
36
37 func NewConfig(env *AppConfig, db *gorm.DB) (*config, error) {
38 cfg := &config{
39 db: db,
40 cache: map[string]map[string]string{},
41 }
42 err := cfg.init(env)
43 if err != nil {
44 return nil, err
45 }
46
47 return cfg, nil
48 }
49
50 func (cfg *config) init(env *AppConfig) error {
51 cfg.Env = env
52
53 // Only set Relay from env if not already configured in DB
54 if cfg.Env.Relay != "" {
55 existingRelay, _ := cfg.Get("Relay", "")
56 if existingRelay == "" {
57 err := cfg.SetUpdate("Relay", cfg.Env.Relay, "")
58 if err != nil {
59 return err
60 }
61 }
62 }
63 if cfg.Env.LNBackendType != "" {
64 err := cfg.SetIgnore("LNBackendType", cfg.Env.LNBackendType, "")
65 if err != nil {
66 return err
67 }
68 }
69
70 // LND specific to support env variables
71 if cfg.Env.LNDAddress != "" {
72 err := cfg.SetUpdate("LNDAddress", cfg.Env.LNDAddress, "")
73 if err != nil {
74 return err
75 }
76 }
77 if cfg.Env.LNDCertFile != "" {
78 certBytes, err := os.ReadFile(cfg.Env.LNDCertFile)
79 if err != nil {
80 logger.Logger.WithError(err).Error("Failed to read LND cert file")
81 return err
82 }
83 certHex := hex.EncodeToString(certBytes)
84 err = cfg.SetUpdate("LNDCertHex", certHex, "")
85 if err != nil {
86 return err
87 }
88 } else if cfg.Env.LNBackendType == "LND" {
89 // If no LNDCertFile is provided, clear any stored certificate
90 // hex value so that no certificate is used for TLS verification.
91 err := cfg.SetUpdate("LNDCertHex", "", "")
92 if err != nil {
93 return err
94 }
95 }
96 if cfg.Env.LNDMacaroonFile != "" {
97 macBytes, err := os.ReadFile(cfg.Env.LNDMacaroonFile)
98 if err != nil {
99 logger.Logger.WithError(err).Error("Failed to read LND macaroon file")
100 return err
101 }
102 macHex := hex.EncodeToString(macBytes)
103 err = cfg.SetUpdate("LNDMacaroonHex", macHex, "")
104 if err != nil {
105 return err
106 }
107 }
108 // Phoenix specific to support env variables
109 if cfg.Env.PhoenixdAddress != "" {
110 err := cfg.SetIgnore("PhoenixdAddress", cfg.Env.PhoenixdAddress, "")
111 if err != nil {
112 return err
113 }
114 }
115 if cfg.Env.PhoenixdAuthorization != "" {
116 err := cfg.SetIgnore("PhoenixdAuthorization", cfg.Env.PhoenixdAuthorization, "")
117 if err != nil {
118 return err
119 }
120 }
121
122 // CLN specific to support env variables
123 if cfg.Env.CLNAddress != "" {
124 err := cfg.SetUpdate("CLNAddress", cfg.Env.CLNAddress, "")
125 if err != nil {
126 return err
127 }
128 }
129 if cfg.Env.CLNLightningDir != "" {
130 err := cfg.SetUpdate("CLNLightningDir", cfg.Env.CLNLightningDir, "")
131 if err != nil {
132 return err
133 }
134 }
135 if cfg.Env.CLNAddressHold != "" {
136 err := cfg.SetUpdate("CLNAddressHold", cfg.Env.CLNAddressHold, "")
137 if err != nil {
138 return err
139 }
140 }
141
142 return nil
143 }
144
145 func (cfg *config) SetupCompleted() (bool, error) {
146 nodeLastStartTime, err := cfg.Get("NodeLastStartTime", "")
147 if err != nil {
148 return false, err
149 }
150
151 logger.Logger.WithFields(logrus.Fields{
152 "has_node_last_start_time": nodeLastStartTime != "",
153 }).Debug("Checking if setup is completed")
154 return nodeLastStartTime != "", nil
155 }
156
157 func (cfg *config) GetJWTSecret() (string, error) {
158 cfg.jwtSecretMutex.Lock()
159 jwtSecret := cfg.jwtSecret
160 cfg.jwtSecretMutex.Unlock()
161
162 if jwtSecret == "" {
163 return "", errors.New("config not unlocked")
164 }
165
166 return jwtSecret, nil
167 }
168
169 // Decrypt and store the JWT secret in memory
170 func (cfg *config) LoadJWTSecret(encryptionKey string) error {
171 if !cfg.CheckUnlockPassword(encryptionKey) {
172 return errors.New("incorrect password")
173 }
174
175 cfg.jwtSecretMutex.Lock()
176 if cfg.jwtSecret != "" {
177 cfg.jwtSecretMutex.Unlock()
178 return nil
179 }
180 cfg.jwtSecretMutex.Unlock()
181
182 // TODO: remove encryptedJwtSecret check after 2027-01-01
183 // - all hubs should have updated to use an encrypted JWT secret by then
184 encryptedJwtSecret, err := cfg.Get("JWTSecret", "")
185 if err != nil {
186 return err
187 }
188 jwtSecret, err := cfg.Get("JWTSecret", encryptionKey)
189 if err != nil {
190 return err
191 }
192 // generate a new one if none exists yet OR if the user has an unencrypted secret
193 if jwtSecret == "" || jwtSecret == encryptedJwtSecret {
194 hexSecret, err := randomHex(32)
195 if err != nil {
196 logger.Logger.WithError(err).Error("failed to generate JWT secret")
197 return err
198 }
199 jwtSecret = hexSecret
200 logger.Logger.Info("Generated new JWT secret")
201
202 err = cfg.SetUpdate("JWTSecret", jwtSecret, encryptionKey)
203 if err != nil {
204 logger.Logger.WithError(err).Error("failed to save JWT secret")
205 return err
206 }
207 }
208 cfg.jwtSecretMutex.Lock()
209 cfg.jwtSecret = jwtSecret
210 cfg.jwtSecretMutex.Unlock()
211 return nil
212 }
213
214 func (cfg *config) GetRelayUrls() []string {
215 relayUrls, _ := cfg.Get("Relay", "")
216 return strings.Split(relayUrls, ",")
217 }
218
219 func (cfg *config) GetNetwork() string {
220 env := cfg.GetEnv()
221
222 if env.Network != "" {
223 return env.Network
224 }
225
226 if env.LDKNetwork != "" {
227 return env.LDKNetwork
228 }
229
230 return "bitcoin"
231 }
232
233 func (cfg *config) GetMempoolUrl() string {
234 mempoolApiUrl := cfg.GetEnv().MempoolApi
235 return strings.TrimSuffix(mempoolApiUrl, "/api")
236 }
237
238 func (cfg *config) getEncryptionKeyHash(encryptionKey string) string {
239 if encryptionKey == "" {
240 return ""
241 }
242 hash := sha256.Sum256([]byte(encryptionKey))
243 // For cache key purposes, 8 bytes (16 hex chars) provides:
244 // 2^64 possible values = ~18 quintillion combinations
245 // More than sufficient to avoid collisions for cache keys
246 return hex.EncodeToString(hash[:8])
247 }
248
249 func (cfg *config) Get(key string, encryptionKey string) (string, error) {
250 cfg.cacheMutex.Lock()
251 defer cfg.cacheMutex.Unlock()
252
253 encKeyHash := cfg.getEncryptionKeyHash(encryptionKey)
254
255 if keyCache, ok := cfg.cache[key]; ok {
256 if cachedValue, ok := keyCache[encKeyHash]; ok {
257 logger.Logger.WithField("key", key).Debug("hit config cache")
258 return cachedValue, nil
259 }
260 }
261 logger.Logger.WithField("key", key).Debug("missed config cache")
262
263 value, err := cfg.get(key, encryptionKey, cfg.db)
264 if err != nil {
265 return "", err
266 }
267
268 if cfg.cache[key] == nil {
269 cfg.cache[key] = make(map[string]string)
270 }
271 cfg.cache[key][encKeyHash] = value
272 logger.Logger.WithField("key", key).Debug("set config cache")
273 return value, nil
274 }
275
276 func (cfg *config) get(key string, encryptionKey string, gormDB *gorm.DB) (string, error) {
277 var userConfig db.UserConfig
278 err := gormDB.Where(&db.UserConfig{Key: key}).Limit(1).Find(&userConfig).Error
279 if err != nil {
280 return "", fmt.Errorf("failed to get configuration value: %w", gormDB.Error)
281 }
282
283 value := userConfig.Value
284 if userConfig.Value != "" && encryptionKey != "" && userConfig.Encrypted {
285 decrypted, err := AesGcmDecryptWithPassword(value, encryptionKey)
286 if err != nil {
287 return "", err
288 }
289 value = decrypted
290 }
291 return value, nil
292 }
293
294 func (cfg *config) set(key string, value string, clauses clause.OnConflict, encryptionKey string, gormDB *gorm.DB) error {
295 if encryptionKey != "" {
296 encrypted, err := AesGcmEncryptWithPassword(value, encryptionKey)
297 if err != nil {
298 return fmt.Errorf("failed to encrypt: %v", err)
299 }
300 value = encrypted
301 }
302 userConfig := db.UserConfig{Key: key, Value: value, Encrypted: encryptionKey != ""}
303 result := gormDB.Clauses(clauses).Create(&userConfig)
304
305 if result.Error != nil {
306 return fmt.Errorf("failed to save key to config: %v", result.Error)
307 }
308
309 logger.Logger.WithField("key", key).Debug("clearing config cache")
310 cfg.cacheMutex.Lock()
311 defer cfg.cacheMutex.Unlock()
312 delete(cfg.cache, key)
313
314 return nil
315 }
316
317 func (cfg *config) SetIgnore(key string, value string, encryptionKey string) error {
318 clauses := clause.OnConflict{
319 Columns: []clause.Column{{Name: "key"}},
320 DoNothing: true,
321 }
322 err := cfg.set(key, value, clauses, encryptionKey, cfg.db)
323 if err != nil {
324 logger.Logger.WithField("key", key).WithError(err).Error("Failed to set config key with ignore", err)
325 return err
326 }
327 return nil
328 }
329
330 func (cfg *config) SetUpdate(key string, value string, encryptionKey string) error {
331 clauses := clause.OnConflict{
332 Columns: []clause.Column{{Name: "key"}},
333 DoUpdates: clause.AssignmentColumns([]string{"value", "encrypted"}),
334 }
335 err := cfg.set(key, value, clauses, encryptionKey, cfg.db)
336 if err != nil {
337 logger.Logger.WithField("key", key).WithError(err).Error("Failed to set config key with update", err)
338 return err
339 }
340 return nil
341 }
342
343 func (cfg *config) ChangeUnlockPassword(currentUnlockPassword string, newUnlockPassword string) error {
344 if newUnlockPassword == "" {
345 return errors.New("new unlock password must not be empty")
346 }
347 if !cfg.CheckUnlockPassword(currentUnlockPassword) {
348 return errors.New("incorrect password")
349 }
350 err := cfg.db.Transaction(func(tx *gorm.DB) error {
351
352 var encryptedUserConfigs []db.UserConfig
353 err := tx.Where(&db.UserConfig{Encrypted: true}).Find(&encryptedUserConfigs).Error
354 if err != nil {
355 return err
356 }
357
358 logger.Logger.WithField("count", len(encryptedUserConfigs)).Info("Updating encrypted entries")
359
360 for _, userConfig := range encryptedUserConfigs {
361 decryptedValue, err := cfg.get(userConfig.Key, currentUnlockPassword, tx)
362 if err != nil {
363 logger.Logger.WithField("key", userConfig.Key).WithError(err).Error("Failed to decrypt key")
364 return err
365 }
366 clauses := clause.OnConflict{
367 Columns: []clause.Column{{Name: "key"}},
368 DoUpdates: clause.AssignmentColumns([]string{"value"}),
369 }
370 err = cfg.set(userConfig.Key, decryptedValue, clauses, newUnlockPassword, tx)
371 if err != nil {
372 logger.Logger.WithField("key", userConfig.Key).WithError(err).Error("Failed to encrypt key")
373 return err
374 }
375 logger.Logger.WithField("key", userConfig.Key).Info("re-encrypted key")
376 }
377
378 // delete the JWT secret so it will be re-generated on next unlock (to log all sessions out on password change)
379 err = tx.Where(&db.UserConfig{Key: "JWTSecret"}).Delete(&db.UserConfig{}).Error
380 if err != nil {
381 logger.Logger.WithError(err).Error("failed to remove JWT secret during password change transaction")
382 return fmt.Errorf("failed to delete new JWT secret: %w", err)
383 }
384
385 logger.Logger.Info("Successfully removed JWT secret as part of password change transaction")
386 return nil
387 })
388
389 if err != nil {
390 logger.Logger.WithError(err).Error("failed to execute password change transaction")
391 return err
392 }
393
394 // JWT secret will be set on config unlock (required after password change)
395 cfg.jwtSecretMutex.Lock()
396 cfg.jwtSecret = ""
397 cfg.jwtSecretMutex.Unlock()
398 return nil
399 }
400
401 func (cfg *config) SetAutoUnlockPassword(unlockPassword string) error {
402 if unlockPassword != "" && !cfg.CheckUnlockPassword(unlockPassword) {
403 return errors.New("incorrect password")
404 }
405
406 err := cfg.SetUpdate("AutoUnlockPassword", unlockPassword, "")
407 if err != nil {
408 logger.Logger.WithError(err).Error("failed to update auto unlock password")
409 return err
410 }
411
412 return nil
413 }
414
415 // GetSessionSecret returns a persistent random secret used to sign NIP-07 UI
416 // session tokens. It is stored unencrypted so it is available without the
417 // unlock password, unlike the JWT secret used for password-based sessions.
418 func (cfg *config) GetSessionSecret() (string, error) {
419 cfg.sessionSecretMutex.Lock()
420 defer cfg.sessionSecretMutex.Unlock()
421
422 if cfg.sessionSecret != "" {
423 return cfg.sessionSecret, nil
424 }
425
426 secret, err := cfg.Get("SessionSecret", "")
427 if err != nil {
428 return "", err
429 }
430 if secret == "" {
431 secret, err = randomHex(32)
432 if err != nil {
433 logger.Logger.WithError(err).Error("failed to generate session secret")
434 return "", err
435 }
436 err = cfg.SetUpdate("SessionSecret", secret, "")
437 if err != nil {
438 logger.Logger.WithError(err).Error("failed to save session secret")
439 return "", err
440 }
441 }
442
443 cfg.sessionSecret = secret
444 return secret, nil
445 }
446
447 // GetNip07OwnerPubkey returns the hex public key authorized to unlock the UI
448 // via NIP-07. Empty when NIP07_PUBKEY is not configured.
449 func (cfg *config) GetNip07OwnerPubkey() string {
450 configured := cfg.Env.Nip07Pubkey
451 if configured == "" {
452 return ""
453 }
454
455 if strings.HasPrefix(configured, "npub1") {
456 _, value, err := nip19.Decode(configured)
457 if err != nil {
458 logger.Logger.WithError(err).Error("failed to decode NIP07_PUBKEY")
459 return ""
460 }
461 hexPubkey, ok := value.(string)
462 if !ok {
463 logger.Logger.Error("NIP07_PUBKEY decoded to unexpected type")
464 return ""
465 }
466 return hexPubkey
467 }
468
469 return configured
470 }
471
472 func (cfg *config) CheckUnlockPassword(encryptionKey string) bool {
473 decryptedValue, err := cfg.Get("UnlockPasswordCheck", encryptionKey)
474
475 // require a non-empty match so an absent or empty canary always fails
476 return err == nil && decryptedValue != "" && decryptedValue == unlockPasswordCheck
477 }
478
479 func (cfg *config) IsUnlockPasswordCheckSet() (bool, error) {
480 // Read the raw value with an empty encryption key so we can detect the
481 // presence of the canary row without needing the (possibly wrong) password.
482 value, err := cfg.Get("UnlockPasswordCheck", "")
483 if err != nil {
484 return false, fmt.Errorf("read unlock password check: %w", err)
485 }
486 return value != "", nil
487 }
488
489 func (cfg *config) SaveUnlockPasswordCheck(encryptionKey string) error {
490 err := cfg.SetUpdate("UnlockPasswordCheck", unlockPasswordCheck, encryptionKey)
491 if err != nil {
492 logger.Logger.WithError(err).Error("Failed to save unlock password check to config")
493 return err
494 }
495 return nil
496 }
497
498 func (cfg *config) GetEnv() *AppConfig {
499 return cfg.Env
500 }
501
502 func randomHex(n int) (string, error) {
503 bytes := make([]byte, n)
504 if _, err := rand.Read(bytes); err != nil {
505 return "", err
506 }
507 return hex.EncodeToString(bytes), nil
508 }
509
510 const defaultCurrency = "USD"
511 const defaultBitcoinDisplayFormat = constants.BITCOIN_DISPLAY_FORMAT_BIP177
512
513 func (cfg *config) GetCurrency() string {
514 currency, err := cfg.Get("Currency", "")
515 if err != nil {
516 logger.Logger.WithError(err).Error("Failed to fetch currency")
517 return defaultCurrency
518 }
519 if currency == "" {
520 return defaultCurrency
521 }
522 return currency
523 }
524
525 func (cfg *config) SetCurrency(value string) error {
526 if value == "" {
527 return errors.New("currency value cannot be empty")
528 }
529 err := cfg.SetUpdate("Currency", value, "")
530 if err != nil {
531 logger.Logger.WithError(err).Error("Failed to update currency")
532 return err
533 }
534 return nil
535 }
536
537 func (cfg *config) GetBitcoinDisplayFormat() string {
538 format, err := cfg.Get("BitcoinDisplayFormat", "")
539 if err != nil {
540 logger.Logger.WithError(err).Error("Failed to fetch bitcoin display format")
541 return defaultBitcoinDisplayFormat
542 }
543 if format == "" {
544 return defaultBitcoinDisplayFormat
545 }
546 return format
547 }
548
549 func (cfg *config) SetBitcoinDisplayFormat(value string) error {
550 if value != constants.BITCOIN_DISPLAY_FORMAT_SATS && value != constants.BITCOIN_DISPLAY_FORMAT_BIP177 {
551 return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177)
552 }
553 err := cfg.SetUpdate("BitcoinDisplayFormat", value, "")
554 if err != nil {
555 logger.Logger.WithError(err).Error("Failed to update bitcoin display format")
556 return err
557 }
558 return nil
559 }
560