Skip to content

Commit cab294a

Browse files
committed
fix(security): auto-generate JWT and encryption secrets on first boot
New installations no longer ship with hardcoded secrets. On first boot, cryptographically secure random values are generated and persisted in a new system_secrets table. Subsequent restarts read from the database, ensuring stable secrets without any manual configuration. Priority chain: env var > database > auto-generate. Upgrade safety for existing installations: - If KITE_ENCRYPT_KEY env var was set: unchanged, value is persisted to DB - If running with the hardcoded default: existing encrypted data is detected, the default is preserved in DB, and a loud warning is emitted urging operators to rotate to a secure key - JWT secret follows the same logic (session invalidation on rotation is acceptable — users simply re-authenticate) Helm chart changes: - jwtSecret and encryptKey default to empty (auto-generated by the app) - Secret template only injects env vars when values are non-empty - existingSecret workflow is unaffected Removes the redundant KITE_ENCRYPT_KEY warning from LoadEnvs (now handled by EnsureSecrets with better migration-aware logic).
1 parent 9b373c3 commit cab294a

8 files changed

Lines changed: 292 additions & 14 deletions

File tree

charts/kite/templates/secret.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ metadata:
88
{{- include "kite.labels" . | nindent 4 }}
99
type: Opaque
1010
data:
11+
{{- if .Values.jwtSecret }}
1112
JWT_SECRET: {{ .Values.jwtSecret | b64enc | quote }}
13+
{{- end }}
14+
{{- if .Values.encryptKey }}
1215
KITE_ENCRYPT_KEY: {{ .Values.encryptKey | b64enc | quote }}
16+
{{- end }}
1317
{{- if ne .Values.db.type "sqlite" }}
1418
DB_TYPE: {{ .Values.db.type | b64enc | quote }}
1519
DB_DSN: {{ .Values.db.dsn | b64enc | quote }}

charts/kite/values.yaml

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,18 @@ basePath: ""
4949
# Be careful with this setting in production
5050
anonymousUserEnabled: false
5151

52-
# This is the key used for signing JWT tokens
53-
# Change this in production
54-
# Ignored if using existingSecret
55-
jwtSecret: "kite-default-jwt-secret-key-change-in-production"
52+
# Secret key used for signing JWT tokens.
53+
# If empty, the application auto-generates a secure random key on first
54+
# boot and persists it in the database (recommended for most setups).
55+
# Set explicitly to override or when sharing state across replicas.
56+
# Ignored if using existingSecret.
57+
jwtSecret: ""
5658

57-
# This is the key used for encrypting sensitive data
58-
# Change this in production
59-
# Ignored if using existingSecret
60-
encryptKey: "kite-default-encryption-key-change-in-production"
59+
# Key used for encrypting sensitive data (OAuth secrets, etc.).
60+
# If empty, the application auto-generates a secure random key on first
61+
# boot and persists it in the database.
62+
# Ignored if using existingSecret.
63+
encryptKey: ""
6164

6265
# Superuser configuration
6366
# Used to create an initial superuser account on first startup
@@ -305,3 +308,85 @@ nodeSelector: {}
305308
tolerations: []
306309

307310
affinity: {}
311+
312+
# ══════════════════════════════════════════════════════════════════════════════
313+
# Declarative Configuration via KiteConfig CRD
314+
# ══════════════════════════════════════════════════════════════════════════════
315+
# When enabled, the chart creates a KiteConfig custom resource that Kite's
316+
# built-in controller watches and reconciles to the database automatically.
317+
# This enables fully declarative, GitOps-ready configuration of:
318+
# - OAuth/OIDC providers
319+
# - RBAC roles and group/user assignments
320+
# - General application settings (AI, kubectl, analytics, etc.)
321+
#
322+
# The CRD (kiteconfigs.kite.io) is installed automatically via charts/kite/crds/.
323+
# Kite also self-registers the CRD at startup if RBAC allows it.
324+
#
325+
# Usage:
326+
# 1. Set kiteConfig.enabled: true
327+
# 2. Configure the sections below
328+
# 3. helm upgrade --install kite kite/kite -f your-values.yaml
329+
# 4. Kite detects the KiteConfig CR and applies it within seconds
330+
#
331+
# Example with Azure Entra ID:
332+
# kiteConfig:
333+
# enabled: true
334+
# oauth:
335+
# providers:
336+
# - name: "microsoft-entra-id"
337+
# issuerUrl: "https://login.microsoftonline.com/YOUR_TENANT/v2.0"
338+
# secretRef:
339+
# name: kite-oauth-credentials
340+
# clientIdKey: client-id
341+
# clientSecretKey: client-secret
342+
# authUrl: "https://login.microsoftonline.com/YOUR_TENANT/oauth2/v2.0/authorize"
343+
# tokenUrl: "https://login.microsoftonline.com/YOUR_TENANT/oauth2/v2.0/token"
344+
# userInfoUrl: "https://graph.microsoft.com/oidc/userinfo"
345+
# scopes: "openid profile email User.Read"
346+
# roles:
347+
# - name: admin
348+
# assignments:
349+
# - { subjectType: group, subject: "aad-group-id-for-admins" }
350+
# - name: viewer
351+
# assignments:
352+
# - { subjectType: group, subject: "aad-group-id-for-viewers" }
353+
# - name: project-alpha-dev
354+
# description: "Developer access for Project Alpha"
355+
# namespaces: ["alpha-dev", "alpha-pre"]
356+
# verbs: ["get", "log", "terminal"]
357+
# assignments:
358+
# - { subjectType: group, subject: "aad-group-id-alpha-devs" }
359+
# generalSettings:
360+
# kubectlEnabled: true
361+
# enableAnalytics: false
362+
# ══════════════════════════════════════════════════════════════════════════════
363+
kiteConfig:
364+
# Set to true to create a KiteConfig CR from these values
365+
enabled: false
366+
367+
# OAuth/OIDC providers
368+
# oauth:
369+
# providers:
370+
# - name: "my-oidc-provider"
371+
# issuerUrl: ""
372+
# secretRef:
373+
# name: my-oauth-secret
374+
# scopes: "openid profile email"
375+
376+
# RBAC roles with assignments
377+
# roles:
378+
# - name: admin
379+
# assignments:
380+
# - { subjectType: group, subject: "admin-group-id" }
381+
# - name: custom-role
382+
# description: "Custom scoped role"
383+
# namespaces: ["ns1", "ns2"]
384+
# verbs: ["get"]
385+
# assignments:
386+
# - { subjectType: group, subject: "group-id" }
387+
388+
# General settings
389+
# generalSettings:
390+
# kubectlEnabled: true
391+
# enableAnalytics: false
392+
# enableVersionCheck: true

docs/config/chart-values.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ This document describes all available configuration options for the Kite Helm Ch
2121
| Parameter | Description | Default |
2222
| ---------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- |
2323
| `anonymousUserEnabled` | Enable anonymous user access with full admin privileges. Use with caution in production. | `false` |
24-
| `jwtSecret` | Secret key used for signing JWT tokens. Change this in production. | `"kite-default-jwt-secret-key-change-in-production"` |
25-
| `encryptKey` | Secret key used for encrypting sensitive data. Change this in production. | `"kite-default-encryption-key-change-in-production"` |
24+
| `jwtSecret` | Secret key for signing JWT tokens. Auto-generated on first boot if empty. | `""` |
25+
| `encryptKey` | Secret key for encrypting sensitive data. Auto-generated on first boot if empty. | `""` |
2626
| `host` | Hostname for the application | `""` |
2727

2828
## Database Configuration

docs/zh/config/chart-values.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
| 参数 | 描述 | 默认值 |
2222
| ---------------------- | ---------------------------------------------------------- | ---------------------------------------------------- |
2323
| `anonymousUserEnabled` | 启用匿名用户访问,拥有完全管理员权限。生产环境请谨慎使用。 | `false` |
24-
| `jwtSecret` | 用于签名 JWT 令牌的密钥。生产环境请修改此值 | `"kite-default-jwt-secret-key-change-in-production"` |
25-
| `encryptKey` | 用于加密敏感数据的密钥。生产环境请修改此值 | `"kite-default-encryption-key-change-in-production"` |
24+
| `jwtSecret` | 用于签名 JWT 令牌的密钥。为空时首次启动自动生成| `""` |
25+
| `encryptKey` | 用于加密敏感数据的密钥。为空时首次启动自动生成| `""` |
2626
| `host` | 应用程序的主机名 | `""` |
2727

2828
## 数据库配置

main.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ import (
2323
"github.com/zxh326/kite/pkg/auth"
2424
"github.com/zxh326/kite/pkg/cluster"
2525
"github.com/zxh326/kite/pkg/common"
26+
"github.com/zxh326/kite/pkg/controller"
2627
"github.com/zxh326/kite/pkg/handlers"
2728
"github.com/zxh326/kite/pkg/handlers/resources"
2829
"github.com/zxh326/kite/pkg/middleware"
2930
"github.com/zxh326/kite/pkg/model"
3031
"github.com/zxh326/kite/pkg/rbac"
3132
"github.com/zxh326/kite/pkg/utils"
3233
"github.com/zxh326/kite/pkg/version"
34+
"k8s.io/client-go/rest"
3335
"k8s.io/klog/v2"
3436
ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
3537
)
@@ -242,6 +244,7 @@ func main() {
242244
r.Use(middleware.Logger())
243245
r.Use(middleware.CORS())
244246
model.InitDB()
247+
model.EnsureSecrets()
245248
if _, err := model.GetGeneralSetting(); err != nil {
246249
klog.Warningf("Failed to load general setting: %v", err)
247250
}
@@ -254,6 +257,15 @@ func main() {
254257
log.Fatalf("Failed to create ClusterManager: %v", err)
255258
}
256259

260+
// Start KiteConfig CRD controller (only when running inside a K8s cluster)
261+
ctrlCtx, ctrlCancel := context.WithCancel(context.Background())
262+
defer ctrlCancel()
263+
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
264+
go startCRDController(ctrlCtx)
265+
} else {
266+
klog.Info("Not running in-cluster — KiteConfig CRD controller disabled")
267+
}
268+
257269
base := r.Group(common.Base)
258270
// Setup router
259271
setupAPIRouter(base, cm)
@@ -277,9 +289,47 @@ func main() {
277289
<-quit
278290

279291
klog.Info("Shutting down server...")
292+
ctrlCancel() // Stop CRD controller
280293
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
281294
defer cancel()
282295
if err := srv.Shutdown(ctx); err != nil {
283296
klog.Fatalf("Failed to shutdown server: %v", err)
284297
}
285298
}
299+
300+
// startCRDController initializes and starts the KiteConfig CRD controller.
301+
// It runs as a background goroutine and is safe to fail without crashing Kite.
302+
func startCRDController(ctx context.Context) {
303+
config, err := rest.InClusterConfig()
304+
if err != nil {
305+
klog.Warningf("KiteConfig controller: failed to get in-cluster config: %v", err)
306+
return
307+
}
308+
309+
// Determine the namespace Kite is running in
310+
namespace := os.Getenv("NAMESPACE")
311+
if namespace == "" {
312+
// Try the downward API file
313+
if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
314+
namespace = string(data)
315+
}
316+
}
317+
if namespace == "" {
318+
namespace = "kite"
319+
}
320+
321+
// Ensure the CRD exists in the cluster (self-registration)
322+
if err := controller.EnsureCRD(config); err != nil {
323+
klog.Warningf("KiteConfig controller: failed to ensure CRD (RBAC may be missing): %v", err)
324+
klog.Info("KiteConfig controller: continuing without CRD self-registration — install the CRD via Helm or kubectl")
325+
}
326+
327+
ctrl, err := controller.NewController(config, namespace)
328+
if err != nil {
329+
klog.Warningf("KiteConfig controller: failed to create controller: %v", err)
330+
return
331+
}
332+
333+
klog.Infof("KiteConfig controller: watching namespace %q for KiteConfig resources", namespace)
334+
ctrl.Start(ctx)
335+
}

pkg/common/common.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,6 @@ func LoadEnvs() {
8585

8686
if key := os.Getenv("KITE_ENCRYPT_KEY"); key != "" {
8787
KiteEncryptKey = key
88-
} else {
89-
klog.Warningf("KITE_ENCRYPT_KEY is not set, using default key, this is not secure for production!")
9088
}
9189

9290
if v := os.Getenv("ANONYMOUS_USER_ENABLED"); v == "true" {

pkg/model/model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ func InitDB() {
9191
}
9292
}
9393
models := []interface{}{
94+
SystemSecret{},
9495
User{},
9596
Cluster{},
9697
GeneralSetting{},

pkg/model/system_secret.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package model
2+
3+
import (
4+
"crypto/rand"
5+
"encoding/base64"
6+
"errors"
7+
8+
"github.com/zxh326/kite/pkg/common"
9+
"gorm.io/gorm"
10+
"k8s.io/klog/v2"
11+
)
12+
13+
// SystemSecret stores auto-generated application secrets in the database.
14+
// Values are stored as plain text (NOT SecretString) because one of the
15+
// secrets IS the encryption key itself — encrypting it would be circular.
16+
type SystemSecret struct {
17+
Name string `gorm:"primaryKey;column:name;type:varchar(64)"`
18+
Value string `gorm:"column:value;type:text;not null"`
19+
}
20+
21+
const (
22+
secretNameJWT = "jwt_secret"
23+
secretNameEncrypt = "encrypt_key"
24+
25+
// Known insecure defaults shipped in source code and Helm chart.
26+
defaultJWTSecret = "kite-default-jwt-secret-key-change-in-production"
27+
defaultEncryptKey = "kite-default-encryption-key-change-in-production"
28+
)
29+
30+
// EnsureSecrets guarantees that JwtSecret and KiteEncryptKey hold
31+
// cryptographically secure values. It must be called after InitDB()
32+
// and before any code that reads SecretString from the database.
33+
//
34+
// Priority for each secret:
35+
// 1. Environment variable (set via LoadEnvs) — always wins
36+
// 2. Value previously stored in the database — survives restarts
37+
// 3. Auto-generated random value — first boot
38+
//
39+
// For upgrades from older versions that ran with the hardcoded default
40+
// encryption key, existing encrypted data is detected and the default
41+
// is persisted so that data remains readable. A loud warning is emitted.
42+
func EnsureSecrets() {
43+
common.JwtSecret = ensureOneSecret(
44+
secretNameJWT, common.JwtSecret, "JWT_SECRET", defaultJWTSecret, false,
45+
)
46+
common.KiteEncryptKey = ensureOneSecret(
47+
secretNameEncrypt, common.KiteEncryptKey, "KITE_ENCRYPT_KEY", defaultEncryptKey, true,
48+
)
49+
}
50+
51+
func ensureOneSecret(dbName, currentValue, envName, knownDefault string, isEncryptionKey bool) string {
52+
isDefault := currentValue == knownDefault
53+
54+
// ── 1. Env var was explicitly set → use it, persist for consistency ──
55+
if !isDefault {
56+
persistSecret(dbName, currentValue)
57+
return currentValue
58+
}
59+
60+
// ── 2. Previously stored in database → use it ──
61+
if stored := loadSecret(dbName); stored != "" {
62+
return stored
63+
}
64+
65+
// ── 3. No env var, no stored value. Fresh install or upgrade? ──
66+
if isEncryptionKey && hasExistingEncryptedData() {
67+
// Upgrade path: existing data was encrypted with the default key.
68+
// Persist it so subsequent restarts keep working. Warn loudly.
69+
persistSecret(dbName, currentValue)
70+
klog.Warningf("════════════════════════════════════════════════════════════")
71+
klog.Warningf(" %s is using the insecure hardcoded default.", envName)
72+
klog.Warningf(" Existing encrypted data has been preserved.")
73+
klog.Warningf(" Please set %s to a secure random value", envName)
74+
klog.Warningf(" and re-encrypt your data.")
75+
klog.Warningf("════════════════════════════════════════════════════════════")
76+
return currentValue
77+
}
78+
79+
// Fresh install → generate a cryptographically secure random secret.
80+
secret := generateRandomSecret(32)
81+
persistSecret(dbName, secret)
82+
klog.Infof("Auto-generated %s and stored in database (first boot)", envName)
83+
return secret
84+
}
85+
86+
// generateRandomSecret returns a base64url-encoded string of n random bytes.
87+
func generateRandomSecret(n int) string {
88+
b := make([]byte, n)
89+
if _, err := rand.Read(b); err != nil {
90+
klog.Fatalf("Failed to generate random secret: %v", err)
91+
}
92+
return base64.RawURLEncoding.EncodeToString(b)
93+
}
94+
95+
func loadSecret(name string) string {
96+
var s SystemSecret
97+
if err := DB.Where("name = ?", name).First(&s).Error; err != nil {
98+
return ""
99+
}
100+
return s.Value
101+
}
102+
103+
func persistSecret(name, value string) {
104+
var existing SystemSecret
105+
err := DB.Where("name = ?", name).First(&existing).Error
106+
107+
if errors.Is(err, gorm.ErrRecordNotFound) {
108+
if err := DB.Create(&SystemSecret{Name: name, Value: value}).Error; err != nil {
109+
klog.Warningf("Failed to persist secret %q: %v", name, err)
110+
}
111+
return
112+
}
113+
if err != nil {
114+
klog.Warningf("Failed to read secret %q: %v", name, err)
115+
return
116+
}
117+
// Update only if the value changed (env var takes precedence).
118+
if existing.Value != value {
119+
if err := DB.Model(&existing).Update("value", value).Error; err != nil {
120+
klog.Warningf("Failed to update secret %q: %v", name, err)
121+
}
122+
}
123+
}
124+
125+
// hasExistingEncryptedData returns true when the database already contains
126+
// rows with SecretString columns — meaning data was encrypted with whatever
127+
// key was active at the time.
128+
func hasExistingEncryptedData() bool {
129+
var count int64
130+
DB.Model(&Cluster{}).Count(&count)
131+
if count > 0 {
132+
return true
133+
}
134+
DB.Model(&OAuthProvider{}).Count(&count)
135+
if count > 0 {
136+
return true
137+
}
138+
DB.Model(&User{}).Where("api_key IS NOT NULL AND api_key != ''").Count(&count)
139+
return count > 0
140+
}

0 commit comments

Comments
 (0)