fix: make HTTP start JWT secret loading idempotent (#2165)

* fix: make HTTP start JWT secret loading idempotent

* fix: check unlock password before loading jwt

* fix: use mutex to guard jwtsecret in config
This commit is contained in:
Adithya Vardhan 2026-04-07 16:39:49 +05:30 committed by GitHub
parent 9e93cff8ea
commit 51b867e992
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 110 additions and 75 deletions

View file

@ -19,11 +19,12 @@ import (
)
type config struct {
Env *AppConfig
db *gorm.DB
cache map[string]map[string]string // key -> encryptionKeyHash -> value
cacheMutex sync.Mutex
jwtSecret string
Env *AppConfig
db *gorm.DB
cache map[string]map[string]string // key -> encryptionKeyHash -> value
cacheMutex sync.Mutex
jwtSecret string
jwtSecretMutex sync.Mutex
}
const (
@ -127,18 +128,30 @@ func (cfg *config) SetupCompleted() (bool, error) {
}
func (cfg *config) GetJWTSecret() (string, error) {
if cfg.jwtSecret == "" {
cfg.jwtSecretMutex.Lock()
jwtSecret := cfg.jwtSecret
cfg.jwtSecretMutex.Unlock()
if jwtSecret == "" {
return "", errors.New("config not unlocked")
}
return cfg.jwtSecret, nil
return jwtSecret, nil
}
func (cfg *config) Unlock(encryptionKey string) error {
// Decrypt and store the JWT secret in memory
func (cfg *config) LoadJWTSecret(encryptionKey string) error {
if !cfg.CheckUnlockPassword(encryptionKey) {
return errors.New("incorrect password")
}
cfg.jwtSecretMutex.Lock()
if cfg.jwtSecret != "" {
cfg.jwtSecretMutex.Unlock()
return nil
}
cfg.jwtSecretMutex.Unlock()
// TODO: remove encryptedJwtSecret check after 2027-01-01
// - all hubs should have updated to use an encrypted JWT secret by then
encryptedJwtSecret, err := cfg.Get("JWTSecret", "")
@ -165,7 +178,9 @@ func (cfg *config) Unlock(encryptionKey string) error {
return err
}
}
cfg.jwtSecretMutex.Lock()
cfg.jwtSecret = jwtSecret
cfg.jwtSecretMutex.Unlock()
return nil
}
@ -350,7 +365,9 @@ func (cfg *config) ChangeUnlockPassword(currentUnlockPassword string, newUnlockP
}
// JWT secret will be set on config unlock (required after password change)
cfg.jwtSecretMutex.Lock()
cfg.jwtSecret = ""
cfg.jwtSecretMutex.Unlock()
return nil
}

View file

@ -74,10 +74,10 @@ func (c *AppConfig) GetBaseFrontendUrl() string {
}
type Config interface {
Unlock(encryptionKey string) error
Get(key string, encryptionKey string) (string, error)
SetIgnore(key string, value string, encryptionKey string) error
SetUpdate(key string, value string, encryptionKey string) error
LoadJWTSecret(encryptionKey string) error
GetJWTSecret() (string, error)
GetRelayUrls() []string
GetNetwork() string

View file

@ -211,7 +211,7 @@ func TestSetUpdate_EncryptionKeyToNoEncryptionKey(t *testing.T) {
assert.Equal(t, "value2", updatedValue)
}
func TestJWTSecret_GeneratedOnUnlock(t *testing.T) {
func TestJWTSecret_GeneratedOnLoad(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
@ -222,7 +222,7 @@ func TestJWTSecret_GeneratedOnUnlock(t *testing.T) {
err = cfg.ChangeUnlockPassword("", "123")
require.NoError(t, err)
err = cfg.Unlock("123")
err = cfg.LoadJWTSecret("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()
@ -235,14 +235,35 @@ func TestJWTSecret_GeneratedOnUnlock(t *testing.T) {
require.NoError(t, err)
assert.NotEqual(t, encryptedSecret, decryptedSecret)
// unlock again without doing anything, ensure the same JWT secret is returned
err = cfg.Unlock("123")
// load again without doing anything, ensure the same JWT secret is returned
err = cfg.LoadJWTSecret("123")
require.NoError(t, err)
jwtSecret2, err := cfg.GetJWTSecret()
require.NoError(t, err)
assert.Equal(t, jwtSecret, jwtSecret2)
}
func TestJWTSecret_WrongPassword(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.ChangeUnlockPassword("", "123")
require.NoError(t, err)
err = cfg.SaveUnlockPasswordCheck("123")
require.NoError(t, err)
err = cfg.LoadJWTSecret("123")
require.NoError(t, err)
err = cfg.LoadJWTSecret("wrong")
require.ErrorContains(t, err, "incorrect password")
}
func TestJWTSecret_ChangePassword(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
@ -254,7 +275,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
err = cfg.ChangeUnlockPassword("", "123")
require.NoError(t, err)
err = cfg.Unlock("123")
err = cfg.LoadJWTSecret("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()
@ -267,7 +288,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
newJwtSecret, err := cfg.GetJWTSecret()
require.ErrorContains(t, err, "unlock")
err = cfg.Unlock("1234")
err = cfg.LoadJWTSecret("1234")
require.NoError(t, err)
// a new JWT secret must be generated after password change
@ -277,7 +298,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
assert.NotEqual(t, newJwtSecret, jwtSecret)
}
func TestJWTSecret_ReplaceUnencryptedSecretOnUnlock(t *testing.T) {
func TestJWTSecret_ReplaceUnencryptedSecretOnLoad(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
@ -293,7 +314,7 @@ func TestJWTSecret_ReplaceUnencryptedSecretOnUnlock(t *testing.T) {
err = svc.Cfg.SetUpdate("JWTSecret", oldJwtSecret, "")
require.NoError(t, err)
err = cfg.Unlock("123")
err = cfg.LoadJWTSecret("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()

View file

@ -294,14 +294,11 @@ func (httpSvc *HttpService) startHandler(c echo.Context) error {
})
}
// NOTE: the config is also unlocked as part of the start
// goroutine below. But since we execute start asynchronously it's hard to
// know when the config has been unlocked before being able to create the JWT token
err := httpSvc.cfg.Unlock(startRequest.UnlockPassword)
err := httpSvc.cfg.LoadJWTSecret(startRequest.UnlockPassword)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to unlock config: %s", err.Error()),
Message: fmt.Sprintf("Failed to load JWT secret: %s", err.Error()),
})
}

View file

@ -276,9 +276,9 @@ func (svc *service) StartApp(encryptionKey string) error {
return errors.New("invalid password")
}
err = svc.cfg.Unlock(encryptionKey)
err = svc.cfg.LoadJWTSecret(encryptionKey)
if err != nil {
logger.Logger.WithError(err).Error("Failed to unlock config")
logger.Logger.WithError(err).Error("Failed to load JWT secret")
return err
}

View file

@ -531,6 +531,57 @@ func (_c *MockConfig_GetRelayUrls_Call) RunAndReturn(run func() []string) *MockC
return _c
}
// LoadJWTSecret provides a mock function for the type MockConfig
func (_mock *MockConfig) LoadJWTSecret(encryptionKey string) error {
ret := _mock.Called(encryptionKey)
if len(ret) == 0 {
panic("no return value specified for LoadJWTSecret")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
r0 = returnFunc(encryptionKey)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConfig_LoadJWTSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadJWTSecret'
type MockConfig_LoadJWTSecret_Call struct {
*mock.Call
}
// LoadJWTSecret is a helper method to define mock.On call
// - encryptionKey string
func (_e *MockConfig_Expecter) LoadJWTSecret(encryptionKey interface{}) *MockConfig_LoadJWTSecret_Call {
return &MockConfig_LoadJWTSecret_Call{Call: _e.mock.On("LoadJWTSecret", encryptionKey)}
}
func (_c *MockConfig_LoadJWTSecret_Call) Run(run func(encryptionKey string)) *MockConfig_LoadJWTSecret_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
run(
arg0,
)
})
return _c
}
func (_c *MockConfig_LoadJWTSecret_Call) Return(err error) *MockConfig_LoadJWTSecret_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockConfig_LoadJWTSecret_Call) RunAndReturn(run func(encryptionKey string) error) *MockConfig_LoadJWTSecret_Call {
_c.Call.Return(run)
return _c
}
// SaveUnlockPasswordCheck provides a mock function for the type MockConfig
func (_mock *MockConfig) SaveUnlockPasswordCheck(encryptionKey string) error {
ret := _mock.Called(encryptionKey)
@ -913,54 +964,3 @@ func (_c *MockConfig_SetupCompleted_Call) RunAndReturn(run func() (bool, error))
_c.Call.Return(run)
return _c
}
// Unlock provides a mock function for the type MockConfig
func (_mock *MockConfig) Unlock(encryptionKey string) error {
ret := _mock.Called(encryptionKey)
if len(ret) == 0 {
panic("no return value specified for Unlock")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
r0 = returnFunc(encryptionKey)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockConfig_Unlock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unlock'
type MockConfig_Unlock_Call struct {
*mock.Call
}
// Unlock is a helper method to define mock.On call
// - encryptionKey string
func (_e *MockConfig_Expecter) Unlock(encryptionKey interface{}) *MockConfig_Unlock_Call {
return &MockConfig_Unlock_Call{Call: _e.mock.On("Unlock", encryptionKey)}
}
func (_c *MockConfig_Unlock_Call) Run(run func(encryptionKey string)) *MockConfig_Unlock_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 string
if args[0] != nil {
arg0 = args[0].(string)
}
run(
arg0,
)
})
return _c
}
func (_c *MockConfig_Unlock_Call) Return(err error) *MockConfig_Unlock_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockConfig_Unlock_Call) RunAndReturn(run func(encryptionKey string) error) *MockConfig_Unlock_Call {
_c.Call.Return(run)
return _c
}