From 8920027a9cdc74be6cdfc16305444cf071a23d02 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 11 Oct 2025 20:55:44 +0200 Subject: [PATCH] Load or generate signing key from file --- server/internal/app/init.go | 9 ++- server/internal/db/db.go | 1 - server/internal/db/system.go | 33 --------- server/internal/db/system_test.go | 28 ------- server/internal/secrets/jwt_key.go | 82 ++++++++++++++++++++ server/internal/secrets/jwt_key_test.go | 99 +++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 65 deletions(-) create mode 100644 server/internal/secrets/jwt_key.go create mode 100644 server/internal/secrets/jwt_key_test.go diff --git a/server/internal/app/init.go b/server/internal/app/init.go index 13f6030..b14de7b 100644 --- a/server/internal/app/init.go +++ b/server/internal/app/init.go @@ -52,11 +52,14 @@ func initAuth(cfg *Config, database db.Database) (auth.JWTManager, auth.SessionM // Get or generate JWT signing key signingKey := cfg.JWTSigningKey if signingKey == "" { - logging.Debug("no JWT signing key provided, generating new key") + logging.Debug("no JWT signing key provided, loading/generating from file") + + // Load or generate key from file + secretsDir := cfg.WorkDir + "/secrets" var err error - signingKey, err = database.EnsureJWTSecret() + signingKey, err = secrets.EnsureJWTSigningKey(secretsDir) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err) + return nil, nil, nil, fmt.Errorf("failed to ensure JWT signing key: %w", err) } } diff --git a/server/internal/db/db.go b/server/internal/db/db.go index e4e873b..a5865b6 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -71,7 +71,6 @@ type SessionStore interface { // SystemStore defines the methods for interacting with system settings and stats in the database type SystemStore interface { GetSystemStats() (*UserStats, error) - EnsureJWTSecret() (string, error) GetSystemSetting(key string) (string, error) SetSystemSetting(key, value string) error } diff --git a/server/internal/db/system.go b/server/internal/db/system.go index ae51fb8..18ada6a 100644 --- a/server/internal/db/system.go +++ b/server/internal/db/system.go @@ -6,11 +6,6 @@ import ( "fmt" ) -const ( - // JWTSecretKey is the key for the JWT secret in the system settings - JWTSecretKey = "jwt_secret" -) - // UserStats represents system-wide statistics type UserStats struct { TotalUsers int `json:"totalUsers"` @@ -18,34 +13,6 @@ type UserStats struct { ActiveUsers int `json:"activeUsers"` // Users with activity in last 30 days } -// EnsureJWTSecret makes sure a JWT signing secret exists in the database -// If no secret exists, it generates and stores a new one -func (db *database) EnsureJWTSecret() (string, error) { - log := getLogger().WithGroup("system") - - // First, try to get existing secret - secret, err := db.GetSystemSetting(JWTSecretKey) - if err == nil { - return secret, nil - } - - // Generate new secret if none exists - newSecret, err := generateRandomSecret(32) // 256 bits - if err != nil { - return "", fmt.Errorf("failed to generate JWT secret: %w", err) - } - - // Store the new secret - err = db.SetSystemSetting(JWTSecretKey, newSecret) - if err != nil { - return "", fmt.Errorf("failed to store JWT secret: %w", err) - } - - log.Info("new JWT secret generated and stored") - - return newSecret, nil -} - // GetSystemSetting retrieves a system setting by key func (db *database) GetSystemSetting(key string) (string, error) { var value string diff --git a/server/internal/db/system_test.go b/server/internal/db/system_test.go index 9c54dd9..87ddd9c 100644 --- a/server/internal/db/system_test.go +++ b/server/internal/db/system_test.go @@ -1,7 +1,6 @@ package db_test import ( - "encoding/base64" "fmt" "strings" "testing" @@ -118,33 +117,6 @@ func TestSystemOperations(t *testing.T) { } }) - t.Run("EnsureJWTSecret", func(t *testing.T) { - // First call should generate a new secret - secret1, err := database.EnsureJWTSecret() - if err != nil { - t.Fatalf("failed to ensure JWT secret: %v", err) - } - - // Verify the secret is a valid base64-encoded string of sufficient length - decoded, err := base64.StdEncoding.DecodeString(secret1) - if err != nil { - t.Errorf("secret is not valid base64: %v", err) - } - if len(decoded) < 32 { - t.Errorf("secret length = %d, want >= 32", len(decoded)) - } - - // Second call should return the same secret - secret2, err := database.EnsureJWTSecret() - if err != nil { - t.Fatalf("failed to get existing JWT secret: %v", err) - } - - if secret2 != secret1 { - t.Errorf("got different secret on second call") - } - }) - t.Run("GetSystemStats", func(t *testing.T) { // Create some test users and sessions users := []*models.User{ diff --git a/server/internal/secrets/jwt_key.go b/server/internal/secrets/jwt_key.go new file mode 100644 index 0000000..98f03f2 --- /dev/null +++ b/server/internal/secrets/jwt_key.go @@ -0,0 +1,82 @@ +package secrets + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" +) + +const ( + // JWTKeyBytes is the size of the JWT signing key in bytes (256 bits) + JWTKeyBytes = 32 + // JWTKeyFile is the filename for the JWT signing key + JWTKeyFile = "jwt_signing_key" + // JWTKeyPerm is the file permission for the JWT signing key (owner read/write only) + JWTKeyPerm = 0600 +) + +// EnsureJWTSigningKey ensures a JWT signing key exists in the secrets directory. +// If no key exists, it generates and stores a new one with restrictive permissions. +// Returns the base64-encoded signing key. +func EnsureJWTSigningKey(secretsDir string) (string, error) { + log := getLogger() + + // Ensure the secrets directory exists with restrictive permissions + if err := os.MkdirAll(secretsDir, 0700); err != nil { + return "", fmt.Errorf("failed to create secrets directory: %w", err) + } + + keyPath := filepath.Join(secretsDir, JWTKeyFile) + + // Check if the key file already exists + if _, err := os.Stat(keyPath); err == nil { + // Key file exists, read it + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return "", fmt.Errorf("failed to read JWT signing key: %w", err) + } + + key := string(keyBytes) + if key == "" { + return "", fmt.Errorf("JWT signing key file is empty") + } + + log.Debug("loaded existing JWT signing key from file") + return key, nil + } + + // Key file doesn't exist, generate a new key + log.Info("generating new JWT signing key") + key, err := generateJWTSigningKey() + if err != nil { + return "", fmt.Errorf("failed to generate JWT signing key: %w", err) + } + + // Write the key to the file with restrictive permissions + if err := os.WriteFile(keyPath, []byte(key), JWTKeyPerm); err != nil { + return "", fmt.Errorf("failed to write JWT signing key: %w", err) + } + + // Double-check permissions (some systems might ignore mode in WriteFile) + if err := os.Chmod(keyPath, JWTKeyPerm); err != nil { + return "", fmt.Errorf("failed to set JWT signing key permissions: %w", err) + } + + log.Info("JWT signing key generated and stored", "path", keyPath) + return key, nil +} + +// generateJWTSigningKey generates a cryptographically secure random signing key +func generateJWTSigningKey() (string, error) { + keyBytes := make([]byte, JWTKeyBytes) + if _, err := rand.Read(keyBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Encode to base64 for easy storage and handling + key := base64.StdEncoding.EncodeToString(keyBytes) + return key, nil +} + diff --git a/server/internal/secrets/jwt_key_test.go b/server/internal/secrets/jwt_key_test.go new file mode 100644 index 0000000..fb924af --- /dev/null +++ b/server/internal/secrets/jwt_key_test.go @@ -0,0 +1,99 @@ +package secrets + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsureJWTSigningKey(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + secretsDir := filepath.Join(tempDir, "secrets") + + t.Run("generates new key if not exists", func(t *testing.T) { + key, err := EnsureJWTSigningKey(secretsDir) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if key == "" { + t.Fatal("expected non-empty key") + } + + // Check that the key file was created + keyPath := filepath.Join(secretsDir, JWTKeyFile) + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + t.Fatal("expected key file to exist") + } + + // Check file permissions + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("failed to stat key file: %v", err) + } + + perm := info.Mode().Perm() + if perm != JWTKeyPerm { + t.Errorf("expected permissions %o, got %o", JWTKeyPerm, perm) + } + }) + + t.Run("loads existing key", func(t *testing.T) { + // First call to generate the key + key1, err := EnsureJWTSigningKey(secretsDir) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Second call should load the same key + key2, err := EnsureJWTSigningKey(secretsDir) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if key1 != key2 { + t.Error("expected same key on subsequent calls") + } + }) + + t.Run("fails if key file is empty", func(t *testing.T) { + emptyDir := filepath.Join(tempDir, "empty_test") + keyPath := filepath.Join(emptyDir, JWTKeyFile) + + // Create empty key file + if err := os.MkdirAll(emptyDir, 0700); err != nil { + t.Fatalf("failed to create directory: %v", err) + } + if err := os.WriteFile(keyPath, []byte(""), JWTKeyPerm); err != nil { + t.Fatalf("failed to write empty file: %v", err) + } + + _, err := EnsureJWTSigningKey(emptyDir) + if err == nil { + t.Error("expected error for empty key file") + } + }) +} + +func TestGenerateJWTSigningKey(t *testing.T) { + key, err := generateJWTSigningKey() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if key == "" { + t.Fatal("expected non-empty key") + } + + // Check that each generated key is unique + key2, err := generateJWTSigningKey() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if key == key2 { + t.Error("expected different keys on each generation") + } +} +