From 3cb76aae804ee3b308502541ed1836ed28f1e16b Mon Sep 17 00:00:00 2001 From: Tosone Date: Tue, 27 Oct 2026 21:07:31 +0800 Subject: [PATCH] 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/... --- pkg/app/bootstrap/bootstrap.go | 1 + pkg/app/bootstrap/credential.go | 108 +++++++++++++++++++++++++++ pkg/app/bootstrap/credential_test.go | 105 ++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 pkg/app/bootstrap/credential.go create mode 100644 pkg/app/bootstrap/credential_test.go diff --git a/pkg/app/bootstrap/bootstrap.go b/pkg/app/bootstrap/bootstrap.go index 52509da3..7fa22e3c 100644 --- a/pkg/app/bootstrap/bootstrap.go +++ b/pkg/app/bootstrap/bootstrap.go @@ -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}, diff --git a/pkg/app/bootstrap/credential.go b/pkg/app/bootstrap/credential.go new file mode 100644 index 00000000..2d334ad4 --- /dev/null +++ b/pkg/app/bootstrap/credential.go @@ -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 +} diff --git a/pkg/app/bootstrap/credential_test.go b/pkg/app/bootstrap/credential_test.go new file mode 100644 index 00000000..5fb712a2 --- /dev/null +++ b/pkg/app/bootstrap/credential_test.go @@ -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)) +}