Skip to content

Commit cce75c0

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 cce75c0

8 files changed

Lines changed: 260 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ func main() {
242242
r.Use(middleware.Logger())
243243
r.Use(middleware.CORS())
244244
model.InitDB()
245+
model.EnsureSecrets()
245246
if _, err := model.GetGeneralSetting(); err != nil {
246247
klog.Warningf("Failed to load general setting: %v", err)
247248
}

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: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package model
2+
3+
import (
4+
"crypto/rand"
5+
"encoding/base64"
6+
"errors"
7+
"os"
8+
9+
"github.com/zxh326/kite/pkg/common"
10+
"gorm.io/gorm"
11+
"k8s.io/klog/v2"
12+
)
13+
14+
// SystemSecret stores auto-generated application secrets in the database.
15+
// Values are stored as plain text (NOT SecretString) because one of the
16+
// secrets IS the encryption key itself — encrypting it would be circular.
17+
type SystemSecret struct {
18+
Name string `gorm:"primaryKey;column:name;type:varchar(64)"`
19+
Value string `gorm:"column:value;type:text;not null"`
20+
}
21+
22+
const (
23+
secretNameJWT = "jwt_secret"
24+
secretNameEncrypt = "encrypt_key"
25+
26+
// Known insecure defaults shipped in source code and Helm chart.
27+
defaultJWTSecret = "kite-default-jwt-secret-key-change-in-production"
28+
defaultEncryptKey = "kite-default-encryption-key-change-in-production"
29+
)
30+
31+
// EnsureSecrets guarantees that JwtSecret and KiteEncryptKey hold
32+
// cryptographically secure values. It must be called after InitDB()
33+
// and before any code that reads SecretString from the database.
34+
//
35+
// Priority for each secret:
36+
// 1. Environment variable (set via LoadEnvs) — always wins
37+
// 2. Value previously stored in the database — survives restarts
38+
// 3. Auto-generated random value — first boot
39+
//
40+
// For upgrades from older versions that ran with the hardcoded default
41+
// encryption key, existing encrypted data is detected and the default
42+
// is persisted so that data remains readable. A loud warning is emitted.
43+
func EnsureSecrets() {
44+
common.JwtSecret = ensureOneSecret(
45+
secretNameJWT, common.JwtSecret, "JWT_SECRET", defaultJWTSecret, false,
46+
os.Getenv("JWT_SECRET") != "",
47+
)
48+
common.KiteEncryptKey = ensureOneSecret(
49+
secretNameEncrypt, common.KiteEncryptKey, "KITE_ENCRYPT_KEY", defaultEncryptKey, true,
50+
os.Getenv("KITE_ENCRYPT_KEY") != "",
51+
)
52+
}
53+
54+
func ensureOneSecret(dbName, currentValue, envName, knownDefault string, isEncryptionKey, envWasSet bool) string {
55+
// ── 1. Env var was explicitly set → use it directly ──
56+
// Do NOT persist to the database: operators who provide secrets via
57+
// env vars expect them to stay outside the DB. Writing them to
58+
// system_secrets would be a security regression (DB-only read
59+
// exposure would reveal the signing/encryption keys).
60+
if envWasSet {
61+
return currentValue
62+
}
63+
64+
// ── 2. Previously stored in database → use it ──
65+
if stored := loadSecret(dbName); stored != "" {
66+
return stored
67+
}
68+
69+
// ── 3. No env var, no stored value. Fresh install or upgrade? ──
70+
if isEncryptionKey && hasExistingEncryptedData() {
71+
// Upgrade path: existing data was encrypted with the default key.
72+
// Persist it so subsequent restarts keep working. Warn loudly.
73+
persistSecret(dbName, currentValue)
74+
klog.Warningf("════════════════════════════════════════════════════════════")
75+
klog.Warningf(" %s is using the insecure hardcoded default.", envName)
76+
klog.Warningf(" Existing encrypted data has been preserved.")
77+
klog.Warningf(" Please set %s to a secure random value", envName)
78+
klog.Warningf(" and re-encrypt your data.")
79+
klog.Warningf("════════════════════════════════════════════════════════════")
80+
return currentValue
81+
}
82+
83+
// Fresh install → generate a cryptographically secure random secret.
84+
// persistSecret returns the winner's value on insert conflict.
85+
secret := persistSecret(dbName, generateRandomSecret(32))
86+
klog.Infof("Auto-generated %s and stored in database (first boot)", envName)
87+
return secret
88+
}
89+
90+
// generateRandomSecret returns a base64url-encoded string of n random bytes.
91+
func generateRandomSecret(n int) string {
92+
b := make([]byte, n)
93+
if _, err := rand.Read(b); err != nil {
94+
klog.Fatalf("Failed to generate random secret: %v", err)
95+
}
96+
return base64.RawURLEncoding.EncodeToString(b)
97+
}
98+
99+
func loadSecret(name string) string {
100+
var s SystemSecret
101+
if err := DB.Where("name = ?", name).First(&s).Error; err != nil {
102+
return ""
103+
}
104+
return s.Value
105+
}
106+
107+
// persistSecret inserts the secret into the database if no row exists yet.
108+
// If a row already exists (another replica won the race, or a previous boot
109+
// stored it), the existing value is returned unchanged — never overwritten.
110+
// This ensures all pods converge on whichever value was written first.
111+
func persistSecret(name, value string) string {
112+
var existing SystemSecret
113+
err := DB.Where("name = ?", name).First(&existing).Error
114+
115+
if errors.Is(err, gorm.ErrRecordNotFound) {
116+
if err := DB.Create(&SystemSecret{Name: name, Value: value}).Error; err != nil {
117+
// Another replica may have inserted first — adopt the winner.
118+
if stored := loadSecret(name); stored != "" {
119+
klog.Infof("Secret %q was created by another instance, adopting its value", name)
120+
return stored
121+
}
122+
klog.Warningf("Failed to persist secret %q: %v", name, err)
123+
}
124+
return value
125+
}
126+
if err != nil {
127+
klog.Warningf("Failed to read secret %q: %v", name, err)
128+
return value
129+
}
130+
// Row already exists — adopt the stored value (first writer wins).
131+
return existing.Value
132+
}
133+
134+
// hasExistingEncryptedData returns true when the database already contains
135+
// rows with non-empty SecretString columns — meaning data was encrypted
136+
// with whatever key was active at the time. Only checks columns that
137+
// actually hold encrypted payloads (not mere row existence).
138+
func hasExistingEncryptedData() bool {
139+
var count int64
140+
// Cluster.Config is a SecretString; in-cluster entries may have it empty.
141+
DB.Model(&Cluster{}).Where("config IS NOT NULL AND config != ''").Count(&count)
142+
if count > 0 {
143+
return true
144+
}
145+
// OAuthProvider.ClientSecret is always encrypted when present.
146+
DB.Model(&OAuthProvider{}).Where("client_secret IS NOT NULL AND client_secret != ''").Count(&count)
147+
if count > 0 {
148+
return true
149+
}
150+
DB.Model(&User{}).Where("api_key IS NOT NULL AND api_key != ''").Count(&count)
151+
if count > 0 {
152+
return true
153+
}
154+
// GeneralSetting.AIAPIKey is also a SecretString field.
155+
DB.Model(&GeneralSetting{}).Where("ai_api_key IS NOT NULL AND ai_api_key != ''").Count(&count)
156+
return count > 0
157+
}

0 commit comments

Comments
 (0)