mirror of
https://github.com/go-sigma/sigma.git
synced 2026-10-28 14:34:34 +08:00
feat(bootstrap): generate the credential master key on first start
auth.credentialKey is optional, so a deployment must work without operator input. Generate a random 32-byte key on first start and persist it in the settings table, which keeps the credential service able to decrypt values written by an earlier process. - run the task right after the services are registered, before signing - take a distributed lock around the check-then-create and re-read inside it, otherwise two replicas starting together would generate different keys and leave half the credentials unreadable - when auth.credentialKey is set it is authoritative: only its length is validated and nothing is written to the settings table Verification: - go test -tags "netgo,timetzdata,exclude_graphdriver_btrfs,containers_image_openpgp" ./pkg/app/bootstrap/...
This commit is contained in:
1 parent
391c380cb5
commit
3cb76aae80
3 files changed
+214
No files matched your search
@@ -30,6 +30,7 @@ type bootstrapTask struct {
|
||||
// bootstrapTasks are startup tasks that run before long-lived commands start.
|
||||
var bootstrapTasks = []bootstrapTask{
|
||||
{name: "service", run: service.InitDigContainer},
|
||||
{name: "credential", run: credentialBootstrap},
|
||||
{name: "signing", run: signing},
|
||||
{name: "user", run: initUser},
|
||||
{name: "namespace", run: initNamespaces},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2026 sigma
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"go.uber.org/dig"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/go-sigma/sigma/pkg/config"
|
||||
"github.com/go-sigma/sigma/pkg/consts"
|
||||
"github.com/go-sigma/sigma/pkg/dal/query"
|
||||
reposetting "github.com/go-sigma/sigma/pkg/dal/repository/setting"
|
||||
"github.com/go-sigma/sigma/pkg/infra/lock"
|
||||
"github.com/go-sigma/sigma/pkg/service/credential"
|
||||
)
|
||||
|
||||
const (
|
||||
// credentialMasterKeyLockKey serializes key generation across replicas.
|
||||
credentialMasterKeyLockKey = "sigma:bootstrap:credential-master-key"
|
||||
credentialMasterKeyLockTTL = 30 * time.Second
|
||||
credentialMasterKeyLockWait = 60 * time.Second
|
||||
)
|
||||
|
||||
type credentialParams struct {
|
||||
dig.In
|
||||
|
||||
Config *config.Configuration
|
||||
SettingRepository reposetting.SettingRepository
|
||||
Locker lock.Locker
|
||||
}
|
||||
|
||||
func credentialBootstrap(digCon *dig.Container) error {
|
||||
return digCon.Invoke(credentialWithParams)
|
||||
}
|
||||
|
||||
// credentialWithParams makes sure a credential master key is available.
|
||||
//
|
||||
// When auth.credentialKey is configured it only validates the key. Otherwise it
|
||||
// generates a random key and persists it in the settings table, so that replicas
|
||||
// and restarts keep decrypting the credentials written before. A distributed lock
|
||||
// plus a second read keeps concurrent first starts from writing different keys.
|
||||
func credentialWithParams(params credentialParams) error {
|
||||
if key := params.Config.Auth.CredentialKey; key != "" {
|
||||
if len(key) < credential.MinMasterKeyLength {
|
||||
return fmt.Errorf("auth.credentialKey must be at least %d bytes", credential.MinMasterKeyLength)
|
||||
}
|
||||
slog.Info("credential master key loaded from config")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
lockObj, err := params.Locker.Acquire(ctx, credentialMasterKeyLockKey, credentialMasterKeyLockTTL, credentialMasterKeyLockWait)
|
||||
if err != nil {
|
||||
slog.Error("acquire credential master key lock failed", "err", err)
|
||||
return fmt.Errorf("acquire credential master key lock failed: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if e := lockObj.Unlock(ctx); e != nil {
|
||||
slog.Error("release credential master key lock failed", "err", e)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = params.SettingRepository.Get(ctx, consts.SettingCredentialMasterKey)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
slog.Error("get credential master key failed", "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
key := make([]byte, credential.MinMasterKeyLength)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
slog.Error("generate credential master key failed", "err", err)
|
||||
return fmt.Errorf("generate credential master key failed: %v", err)
|
||||
}
|
||||
|
||||
err = query.Q.Transaction(func(tx *query.Query) error {
|
||||
txSettingRepository := reposetting.NewSettingRepository(tx)
|
||||
return txSettingRepository.Create(ctx, consts.SettingCredentialMasterKey, key)
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("create credential master key failed", "err", err)
|
||||
return err
|
||||
}
|
||||
slog.Info("credential master key generated and stored in settings")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 sigma
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/dig"
|
||||
|
||||
"github.com/go-sigma/sigma/pkg/api/enums"
|
||||
"github.com/go-sigma/sigma/pkg/config"
|
||||
"github.com/go-sigma/sigma/pkg/consts"
|
||||
reposetting "github.com/go-sigma/sigma/pkg/dal/repository/setting"
|
||||
"github.com/go-sigma/sigma/pkg/service/credential"
|
||||
"github.com/go-sigma/sigma/pkg/testkit"
|
||||
)
|
||||
|
||||
func TestCredentialBootstrapGeneratesKey(t *testing.T) {
|
||||
cfg := &config.Configuration{
|
||||
Locker: config.ConfigurationLocker{
|
||||
Type: enums.LockerTypeRedis,
|
||||
Prefix: "sigma-locker",
|
||||
Redis: config.ConfigurationLockerRedis{},
|
||||
},
|
||||
Redis: config.ConfigurationRedis{
|
||||
URL: "redis://:sigma@localhost:6379/0",
|
||||
},
|
||||
}
|
||||
|
||||
digCon := dig.New()
|
||||
require.NoError(t, digCon.Provide(func() *config.Configuration { return cfg }))
|
||||
require.NoError(t, testkit.InitializeIntegration(t, digCon))
|
||||
|
||||
require.NoError(t, credentialBootstrap(digCon))
|
||||
|
||||
settingRepository := reposetting.NewSettingRepository()
|
||||
settingObj, err := settingRepository.Get(t.Context(), consts.SettingCredentialMasterKey)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, settingObj.Val, credential.MinMasterKeyLength)
|
||||
|
||||
// Running the task again must keep the existing key, otherwise credentials written by
|
||||
// an earlier start could no longer be decrypted.
|
||||
require.NoError(t, credentialBootstrap(digCon))
|
||||
again, err := settingRepository.Get(t.Context(), consts.SettingCredentialMasterKey)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, settingObj.Val, again.Val)
|
||||
}
|
||||
|
||||
func TestCredentialBootstrapWithConfiguredKey(t *testing.T) {
|
||||
cfg := &config.Configuration{
|
||||
Auth: config.ConfigurationAuth{CredentialKey: strings.Repeat("k", credential.MinMasterKeyLength)},
|
||||
Locker: config.ConfigurationLocker{
|
||||
Type: enums.LockerTypeRedis,
|
||||
Prefix: "sigma-locker",
|
||||
Redis: config.ConfigurationLockerRedis{},
|
||||
},
|
||||
Redis: config.ConfigurationRedis{
|
||||
URL: "redis://:sigma@localhost:6379/0",
|
||||
},
|
||||
}
|
||||
|
||||
digCon := dig.New()
|
||||
require.NoError(t, digCon.Provide(func() *config.Configuration { return cfg }))
|
||||
require.NoError(t, testkit.InitializeIntegration(t, digCon))
|
||||
|
||||
require.NoError(t, credentialBootstrap(digCon))
|
||||
|
||||
// A configured key is authoritative: nothing is generated into the settings table.
|
||||
_, err := reposetting.NewSettingRepository().Get(t.Context(), consts.SettingCredentialMasterKey)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCredentialBootstrapRejectsShortKey(t *testing.T) {
|
||||
cfg := &config.Configuration{
|
||||
Auth: config.ConfigurationAuth{CredentialKey: "too-short"},
|
||||
Locker: config.ConfigurationLocker{
|
||||
Type: enums.LockerTypeRedis,
|
||||
Prefix: "sigma-locker",
|
||||
Redis: config.ConfigurationLockerRedis{},
|
||||
},
|
||||
Redis: config.ConfigurationRedis{
|
||||
URL: "redis://:sigma@localhost:6379/0",
|
||||
},
|
||||
}
|
||||
|
||||
digCon := dig.New()
|
||||
require.NoError(t, digCon.Provide(func() *config.Configuration { return cfg }))
|
||||
require.NoError(t, testkit.InitializeIntegration(t, digCon))
|
||||
|
||||
require.Error(t, credentialBootstrap(digCon))
|
||||
}
|
||||
Reference in new issue
Block a user