chore: use encrypted jwt secret (#1988)

* chore: use encrypted jwt secret

* chore: improve jwt secret unlock test

* chore: replace unencrypted jwt secret on unlock

* chore: add TODO to remove encrypted JWT secret check after 2027
This commit is contained in:
Roland 2025-12-17 00:25:33 +07:00 committed by GitHub
parent 7824576214
commit 1f1aacfa8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 248 additions and 60 deletions

View file

@ -19,7 +19,6 @@ FRONTEND_URL=http://localhost:5173
#AUTO_UNLOCK_PASSWORD=123
#WORK_DIR=.data
#DATABASE_URI=nwc.db
#JWT_SECRET=secretsecret
#RELAY=wss://relay.getalby.com/v1
#RELAY=ws://localhost:7447/v1
#PORT=8080

View file

@ -158,7 +158,6 @@ For more information on the Go pprof library, see the [official documentation](h
The following configuration options can be set as environment variables or in a .env file
- `RELAY`: default: "wss://relay.getalby.com/v1" (can support multiple separated by commas)
- `JWT_SECRET`: A randomly generated secret string, applied if no JWT secret is already set. (only needed in http mode). If not provided, one will be automatically generated. On password change, a new JWT secret will be generated.
- `DATABASE_URI`: A sqlite filename or postgres URL. Default is SQLite DB `nwc.db` without a path, which will be put in the user home directory: $XDG_DATA_HOME/albyhub/nwc.db
- `PORT`: The port on which the app should listen on (default: 8080)
- `WORK_DIR`: Directory to store NWC data files. Default: $XDG_DATA_HOME/albyhub

View file

@ -1336,7 +1336,7 @@ var startMutex sync.Mutex
func (api *api) Start(startRequest *StartRequest) {
api.startupError = nil
err := api.StartInternal(startRequest)
err := api.startInternal(startRequest)
if err != nil {
logger.Logger.WithError(err).Error("Failed to start node")
api.startupError = err
@ -1344,7 +1344,7 @@ func (api *api) Start(startRequest *StartRequest) {
}
}
func (api *api) StartInternal(startRequest *StartRequest) (err error) {
func (api *api) startInternal(startRequest *StartRequest) (err error) {
if !startMutex.TryLock() {
// do not allow to start twice in case this is somehow called twice
return errors.New("app is busy")

View file

@ -24,6 +24,7 @@ type config struct {
db *gorm.DB
cache map[string]map[string]string // key -> encryptionKeyHash -> value
cacheMutex sync.Mutex
jwtSecret string
}
const (
@ -111,26 +112,6 @@ func (cfg *config) init(env *AppConfig) error {
}
}
// set the JWT secret from the env, or generate a new one
existingSecret, _ := cfg.Get("JWTSecret", "")
if existingSecret == "" {
jwtSecret := cfg.Env.JWTSecret
if jwtSecret == "" {
hexSecret, err := randomHex(32)
if err != nil {
logger.Logger.WithError(err).Error("failed to generate JWT secret")
return err
}
jwtSecret = hexSecret
logger.Logger.Info("Generated new JWT secret")
}
err := cfg.SetIgnore("JWTSecret", jwtSecret, "")
if err != nil {
logger.Logger.WithError(err).Error("failed to save JWT secret")
return err
}
}
return nil
}
@ -148,13 +129,47 @@ func (cfg *config) SetupCompleted() bool {
return nodeLastStartTime != "" || hasLdkDir
}
func (cfg *config) GetJWTSecret() string {
secret, err := cfg.Get("JWTSecret", "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to retrieve JWTSecret from database")
return ""
func (cfg *config) GetJWTSecret() (string, error) {
if cfg.jwtSecret == "" {
return "", errors.New("config not unlocked")
}
return secret
return cfg.jwtSecret, nil
}
func (cfg *config) Unlock(encryptionKey string) error {
if !cfg.CheckUnlockPassword(encryptionKey) {
return errors.New("incorrect password")
}
// 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", "")
if err != nil {
return err
}
jwtSecret, err := cfg.Get("JWTSecret", encryptionKey)
if err != nil {
return err
}
// generate a new one if none exists yet OR if the user has an unencrypted secret
if jwtSecret == "" || jwtSecret == encryptedJwtSecret {
hexSecret, err := randomHex(32)
if err != nil {
logger.Logger.WithError(err).Error("failed to generate JWT secret")
return err
}
jwtSecret = hexSecret
logger.Logger.Info("Generated new JWT secret")
err = cfg.SetUpdate("JWTSecret", jwtSecret, encryptionKey)
if err != nil {
logger.Logger.WithError(err).Error("failed to save JWT secret")
return err
}
}
cfg.jwtSecret = jwtSecret
return nil
}
func (cfg *config) GetRelayUrls() []string {
@ -321,23 +336,14 @@ func (cfg *config) ChangeUnlockPassword(currentUnlockPassword string, newUnlockP
logger.Logger.WithField("key", userConfig.Key).Info("re-encrypted key")
}
newSecret, err := randomHex(32)
// delete the JWT secret so it will be re-generated on next unlock (to log all sessions out on password change)
err = tx.Where(&db.UserConfig{Key: "JWTSecret"}).Delete(&db.UserConfig{}).Error
if err != nil {
logger.Logger.WithError(err).Error("failed to generate new JWT secret during password change transaction")
return fmt.Errorf("failed to generate new JWT secret: %w", err)
logger.Logger.WithError(err).Error("failed to remove JWT secret during password change transaction")
return fmt.Errorf("failed to delete new JWT secret: %w", err)
}
updateClauses := clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}
err = cfg.set("JWTSecret", newSecret, updateClauses, "", tx)
if err != nil {
logger.Logger.WithError(err).Error("failed to save new JWT secret during password change transaction")
return fmt.Errorf("failed to save new JWT secret: %w", err)
}
logger.Logger.Info("Successfully regenerated JWT secret as part of password change transaction")
logger.Logger.Info("Successfully removed JWT secret as part of password change transaction")
return nil
})
@ -345,6 +351,9 @@ func (cfg *config) ChangeUnlockPassword(currentUnlockPassword string, newUnlockP
logger.Logger.WithError(err).Error("failed to execute password change transaction")
return err
}
// JWT secret will be set on config unlock (required after password change)
cfg.jwtSecret = ""
return nil
}

View file

@ -24,7 +24,6 @@ type AppConfig struct {
Workdir string `envconfig:"WORK_DIR"`
Port string `envconfig:"PORT" default:"8080"`
DatabaseUri string `envconfig:"DATABASE_URI" default:"nwc.db"`
JWTSecret string `envconfig:"JWT_SECRET"`
LogLevel string `envconfig:"LOG_LEVEL" default:"4"`
LogToFile bool `envconfig:"LOG_TO_FILE" default:"true"`
Network string `envconfig:"NETWORK"`
@ -73,10 +72,11 @@ 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
GetJWTSecret() string
GetJWTSecret() (string, error)
GetRelayUrls() []string
GetNetwork() string
GetMempoolUrl() string

View file

@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/tests"
)
@ -209,3 +210,99 @@ func TestSetUpdate_EncryptionKeyToNoEncryptionKey(t *testing.T) {
updatedValue, err := svc.Cfg.Get("key", "")
assert.Equal(t, "value2", updatedValue)
}
func TestJWTSecret_GeneratedOnUnlock(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.Unlock("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()
require.NoError(t, err)
assert.NotEmpty(t, jwtSecret)
encryptedSecret, err := cfg.Get("JWTSecret", "")
require.NoError(t, err)
decryptedSecret, err := cfg.Get("JWTSecret", "123")
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")
require.NoError(t, err)
jwtSecret2, err := cfg.GetJWTSecret()
require.NoError(t, err)
assert.Equal(t, jwtSecret, jwtSecret2)
}
func TestJWTSecret_ChangePassword(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.Unlock("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()
require.NoError(t, err)
assert.NotEmpty(t, jwtSecret)
err = cfg.ChangeUnlockPassword("", "1234")
require.NoError(t, err)
newJwtSecret, err := cfg.GetJWTSecret()
require.ErrorContains(t, err, "unlock")
err = cfg.Unlock("1234")
require.NoError(t, err)
// a new JWT secret must be generated after password change
newJwtSecret, err = cfg.GetJWTSecret()
require.NoError(t, err)
assert.NotEmpty(t, newJwtSecret)
assert.NotEqual(t, newJwtSecret, jwtSecret)
}
func TestJWTSecret_ReplaceUnencryptedSecretOnUnlock(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)
// simulate a hub that had an unencrypted JWT secret
oldJwtSecret := "dummy secret"
err = svc.Cfg.SetUpdate("JWTSecret", oldJwtSecret, "")
require.NoError(t, err)
err = cfg.Unlock("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()
require.NoError(t, err)
assert.NotEmpty(t, jwtSecret)
assert.NotEqual(t, jwtSecret, oldJwtSecret)
// ensure it is saved to DB
jwtSecretFromCfg, err := cfg.Get("JWTSecret", "123")
require.NoError(t, err)
assert.Equal(t, jwtSecret, jwtSecretFromCfg)
}

View file

@ -113,7 +113,11 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
},
// use a custom key func as the JWT secret will change if the user changes their unlock password
KeyFunc: func(token *jwt.Token) (interface{}, error) {
return []byte(httpSvc.cfg.GetJWTSecret()), nil
secret, err := httpSvc.cfg.GetJWTSecret()
if err != nil {
return nil, err
}
return []byte(secret), nil
},
}
// Read-only API group - accessible to both full and readonly tokens
@ -209,7 +213,12 @@ func (httpSvc *HttpService) infoHandler(c echo.Context) error {
if parts[0] == "Bearer" {
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(httpSvc.cfg.GetJWTSecret()), nil
secret, err := httpSvc.cfg.GetJWTSecret()
if err != nil {
return nil, err
}
return []byte(secret), nil
})
if err != nil {
logger.Logger.WithError(err).Error("failed to parse token")
@ -285,6 +294,17 @@ 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)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to unlock config: %s", err.Error()),
})
}
token, err := httpSvc.createJWT(nil, "full")
if err != nil {
@ -438,7 +458,12 @@ func (httpSvc *HttpService) createJWT(tokenExpiryDays *uint64, permission string
return "", errors.New("failed to create token")
}
signed, err := token.SignedString([]byte(httpSvc.cfg.GetJWTSecret()))
secret, err := httpSvc.cfg.GetJWTSecret()
if err != nil {
return "", err
}
signed, err := token.SignedString([]byte(secret))
if err != nil {
return "", err
}

View file

@ -132,7 +132,7 @@ func TestGetApps_ReadonlyPermission(t *testing.T) {
mockConfig := mocks.NewMockConfig(t)
mockConfig.On("GetEnv").Return(&config.AppConfig{})
mockConfig.On("CheckUnlockPassword", "123").Return(true)
mockConfig.On("GetJWTSecret").Return("dummy secret")
mockConfig.On("GetJWTSecret").Return("dummy secret", nil)
mockSvc.On("GetDB").Return(gormDb)
mockSvc.On("GetConfig").Return(mockConfig)
@ -185,7 +185,7 @@ func TestGetApps_FullPermission(t *testing.T) {
mockConfig := mocks.NewMockConfig(t)
mockConfig.On("GetEnv").Return(&config.AppConfig{})
mockConfig.On("CheckUnlockPassword", "123").Return(true)
mockConfig.On("GetJWTSecret").Return("dummy secret")
mockConfig.On("GetJWTSecret").Return("dummy secret", nil)
mockSvc.On("GetDB").Return(gormDb)
mockSvc.On("GetConfig").Return(mockConfig)
@ -270,7 +270,7 @@ func TestCreateApp_FullPermission(t *testing.T) {
mockConfig := mocks.NewMockConfig(t)
mockConfig.On("GetEnv").Return(&config.AppConfig{})
mockConfig.On("CheckUnlockPassword", "123").Return(true)
mockConfig.On("GetJWTSecret").Return("dummy secret")
mockConfig.On("GetJWTSecret").Return("dummy secret", nil)
mockConfig.On("GetRelayUrls").Return([]string{})
mockKeys := mocks.NewMockKeys(t)
@ -334,7 +334,7 @@ func TestCreateApp_ReadonlyPermission(t *testing.T) {
mockConfig := mocks.NewMockConfig(t)
mockConfig.On("GetEnv").Return(&config.AppConfig{})
mockConfig.On("CheckUnlockPassword", "123").Return(true)
mockConfig.On("GetJWTSecret").Return("dummy secret")
mockConfig.On("GetJWTSecret").Return("dummy secret", nil)
mockKeys := mocks.NewMockKeys(t)

View file

@ -276,15 +276,20 @@ func (svc *service) StartApp(encryptionKey string) error {
return errors.New("invalid password")
}
ctx, cancelFn := context.WithCancel(svc.ctx)
err = svc.cfg.Unlock(encryptionKey)
if err != nil {
logger.Logger.WithError(err).Error("Failed to unlock config")
return err
}
err = svc.keys.Init(svc.cfg, encryptionKey)
if err != nil {
logger.Logger.WithError(err).Error("Failed to init nostr keys")
cancelFn()
return err
}
ctx, cancelFn := context.WithCancel(svc.ctx)
svc.startupState = "Launching Node"
err = svc.launchLNBackend(ctx, encryptionKey)
if err != nil {

View file

@ -317,7 +317,7 @@ func (_c *MockConfig_GetEnv_Call) RunAndReturn(run func() *config.AppConfig) *Mo
}
// GetJWTSecret provides a mock function for the type MockConfig
func (_mock *MockConfig) GetJWTSecret() string {
func (_mock *MockConfig) GetJWTSecret() (string, error) {
ret := _mock.Called()
if len(ret) == 0 {
@ -325,12 +325,21 @@ func (_mock *MockConfig) GetJWTSecret() string {
}
var r0 string
var r1 error
if returnFunc, ok := ret.Get(0).(func() (string, error)); ok {
return returnFunc()
}
if returnFunc, ok := ret.Get(0).(func() string); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(string)
}
return r0
if returnFunc, ok := ret.Get(1).(func() error); ok {
r1 = returnFunc()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockConfig_GetJWTSecret_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJWTSecret'
@ -350,12 +359,12 @@ func (_c *MockConfig_GetJWTSecret_Call) Run(run func()) *MockConfig_GetJWTSecret
return _c
}
func (_c *MockConfig_GetJWTSecret_Call) Return(s string) *MockConfig_GetJWTSecret_Call {
_c.Call.Return(s)
func (_c *MockConfig_GetJWTSecret_Call) Return(s string, err error) *MockConfig_GetJWTSecret_Call {
_c.Call.Return(s, err)
return _c
}
func (_c *MockConfig_GetJWTSecret_Call) RunAndReturn(run func() string) *MockConfig_GetJWTSecret_Call {
func (_c *MockConfig_GetJWTSecret_Call) RunAndReturn(run func() (string, error)) *MockConfig_GetJWTSecret_Call {
_c.Call.Return(run)
return _c
}
@ -811,3 +820,48 @@ func (_c *MockConfig_SetupCompleted_Call) RunAndReturn(run func() bool) *MockCon
_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
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) {
run(args[0].(string))
})
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
}