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:
Tosone committed 2026-10-27 21:07:31 +08:00
1 parent 391c380cb5
commit 3cb76aae80
3 files changed
+214

No files matched your search

+1
View File
@@ -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},
+108
View File
@@ -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
}
+105
View File
@@ -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))
}