From 8920027a9cdc74be6cdfc16305444cf071a23d02 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 11 Oct 2025 20:55:44 +0200 Subject: [PATCH 1/5] 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") + } +} + From d40321685e2a82bafdfdb2891fb17d354db925dd Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 11 Oct 2025 21:04:01 +0200 Subject: [PATCH 2/5] Remove system settings functionality and related database migrations --- README.md | 2 +- server/internal/db/db.go | 4 +- .../postgres/001_initial_schema.down.sql | 1 - .../postgres/001_initial_schema.up.sql | 8 -- .../sqlite/001_initial_schema.down.sql | 1 - .../sqlite/001_initial_schema.up.sql | 8 -- server/internal/db/migrations_test.go | 1 - server/internal/db/system.go | 51 ---------- server/internal/db/system_test.go | 94 ------------------- 9 files changed, 2 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 6efa5fc..e185d4f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Lemma can be configured using environment variables. Here are the available conf - `LEMMA_PORT`: Port to run the server on (default: "8080") - `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication - `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins -- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens +- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/jwt_signing_key` - `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production) - `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100) - `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m) diff --git a/server/internal/db/db.go b/server/internal/db/db.go index a5865b6..20925ed 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -68,11 +68,9 @@ type SessionStore interface { CleanExpiredSessions() error } -// SystemStore defines the methods for interacting with system settings and stats in the database +// SystemStore defines the methods for interacting with system stats in the database type SystemStore interface { GetSystemStats() (*UserStats, error) - GetSystemSetting(key string) (string, error) - SetSystemSetting(key, value string) error } type StructScanner interface { diff --git a/server/internal/db/migrations/postgres/001_initial_schema.down.sql b/server/internal/db/migrations/postgres/001_initial_schema.down.sql index e95e055..6e9a1c0 100644 --- a/server/internal/db/migrations/postgres/001_initial_schema.down.sql +++ b/server/internal/db/migrations/postgres/001_initial_schema.down.sql @@ -5,5 +5,4 @@ DROP INDEX IF EXISTS idx_sessions_user_id; DROP INDEX IF EXISTS idx_workspaces_user_id; DROP TABLE IF EXISTS sessions; DROP TABLE IF EXISTS workspaces; -DROP TABLE IF EXISTS system_settings; DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/server/internal/db/migrations/postgres/001_initial_schema.up.sql b/server/internal/db/migrations/postgres/001_initial_schema.up.sql index fca49c6..3d08d60 100644 --- a/server/internal/db/migrations/postgres/001_initial_schema.up.sql +++ b/server/internal/db/migrations/postgres/001_initial_schema.up.sql @@ -46,14 +46,6 @@ CREATE TABLE IF NOT EXISTS sessions ( FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); --- Create system_settings table for application settings -CREATE TABLE IF NOT EXISTS system_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -- Create indexes for performance CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); diff --git a/server/internal/db/migrations/sqlite/001_initial_schema.down.sql b/server/internal/db/migrations/sqlite/001_initial_schema.down.sql index ba0f7de..742ff14 100644 --- a/server/internal/db/migrations/sqlite/001_initial_schema.down.sql +++ b/server/internal/db/migrations/sqlite/001_initial_schema.down.sql @@ -5,5 +5,4 @@ DROP INDEX IF EXISTS idx_sessions_user_id; DROP INDEX IF EXISTS idx_workspaces_user_id; DROP TABLE IF EXISTS sessions; DROP TABLE IF EXISTS workspaces; -DROP TABLE IF EXISTS system_settings; DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/server/internal/db/migrations/sqlite/001_initial_schema.up.sql b/server/internal/db/migrations/sqlite/001_initial_schema.up.sql index 40ef090..a718161 100644 --- a/server/internal/db/migrations/sqlite/001_initial_schema.up.sql +++ b/server/internal/db/migrations/sqlite/001_initial_schema.up.sql @@ -45,14 +45,6 @@ CREATE TABLE IF NOT EXISTS sessions ( FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); --- Create system_settings table for application settings -CREATE TABLE IF NOT EXISTS system_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -- Create indexes for performance CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); diff --git a/server/internal/db/migrations_test.go b/server/internal/db/migrations_test.go index 4998db2..7732de9 100644 --- a/server/internal/db/migrations_test.go +++ b/server/internal/db/migrations_test.go @@ -26,7 +26,6 @@ func TestMigrate(t *testing.T) { "users", "workspaces", "sessions", - "system_settings", "schema_migrations", } diff --git a/server/internal/db/system.go b/server/internal/db/system.go index 18ada6a..1a200a2 100644 --- a/server/internal/db/system.go +++ b/server/internal/db/system.go @@ -1,8 +1,6 @@ package db import ( - "crypto/rand" - "encoding/base64" "fmt" ) @@ -13,55 +11,6 @@ type UserStats struct { ActiveUsers int `json:"activeUsers"` // Users with activity in last 30 days } -// GetSystemSetting retrieves a system setting by key -func (db *database) GetSystemSetting(key string) (string, error) { - var value string - query := db.NewQuery(). - Select("value"). - From("system_settings"). - Where("key = "). - Placeholder(key) - err := db.QueryRow(query.String(), query.args...).Scan(&value) - if err != nil { - return "", err - } - - return value, nil -} - -// SetSystemSetting stores or updates a system setting -func (db *database) SetSystemSetting(key, value string) error { - query := db.NewQuery(). - Insert("system_settings", "key", "value"). - Values(2). - AddArgs(key, value). - Write("ON CONFLICT(key) DO UPDATE SET value = "). - Placeholder(value) - - _, err := db.Exec(query.String(), query.args...) - - if err != nil { - return fmt.Errorf("failed to store system setting: %w", err) - } - - return nil -} - -// generateRandomSecret generates a cryptographically secure random string -func generateRandomSecret(bytes int) (string, error) { - log := getLogger().WithGroup("system") - log.Debug("generating random secret", "bytes", bytes) - - b := make([]byte, bytes) - _, err := rand.Read(b) - if err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) - } - - secret := base64.StdEncoding.EncodeToString(b) - return secret, nil -} - // GetSystemStats returns system-wide statistics func (db *database) GetSystemStats() (*UserStats, error) { stats := &UserStats{} diff --git a/server/internal/db/system_test.go b/server/internal/db/system_test.go index 87ddd9c..38055f7 100644 --- a/server/internal/db/system_test.go +++ b/server/internal/db/system_test.go @@ -2,7 +2,6 @@ package db_test import ( "fmt" - "strings" "testing" "time" @@ -24,99 +23,6 @@ func TestSystemOperations(t *testing.T) { t.Fatalf("failed to run migrations: %v", err) } - t.Run("GetSystemSettings", func(t *testing.T) { - t.Run("non-existent setting", func(t *testing.T) { - _, err := database.GetSystemSetting("nonexistent-key") - if err == nil { - t.Error("expected error for non-existent key, got nil") - } - }) - - t.Run("existing setting", func(t *testing.T) { - // First set a value - err := database.SetSystemSetting("test-key", "test-value") - if err != nil { - t.Fatalf("failed to set system setting: %v", err) - } - - // Then get it back - value, err := database.GetSystemSetting("test-key") - if err != nil { - t.Fatalf("failed to get system setting: %v", err) - } - - if value != "test-value" { - t.Errorf("got value %q, want %q", value, "test-value") - } - }) - }) - - t.Run("SetSystemSettings", func(t *testing.T) { - testCases := []struct { - name string - key string - value string - wantErr bool - errContains string - }{ - { - name: "new setting", - key: "new-key", - value: "new-value", - wantErr: false, - }, - { - name: "update existing setting", - key: "update-key", - value: "original-value", - wantErr: false, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - err := database.SetSystemSetting(tc.key, tc.value) - if tc.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if !strings.Contains(err.Error(), tc.errContains) { - t.Errorf("error = %v, want error containing %v", err, tc.errContains) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify the setting was stored - stored, err := database.GetSystemSetting(tc.key) - if err != nil { - t.Fatalf("failed to retrieve stored setting: %v", err) - } - if stored != tc.value { - t.Errorf("got value %q, want %q", stored, tc.value) - } - - // For the update case, test updating the value - if tc.name == "update existing setting" { - newValue := "updated-value" - err := database.SetSystemSetting(tc.key, newValue) - if err != nil { - t.Fatalf("failed to update setting: %v", err) - } - - stored, err := database.GetSystemSetting(tc.key) - if err != nil { - t.Fatalf("failed to retrieve updated setting: %v", err) - } - if stored != newValue { - t.Errorf("got updated value %q, want %q", stored, newValue) - } - } - }) - } - }) - t.Run("GetSystemStats", func(t *testing.T) { // Create some test users and sessions users := []*models.User{ From 62605b3689b1570513aef0012ec28b6ec87448a4 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 11 Oct 2025 21:18:24 +0200 Subject: [PATCH 3/5] Refactor encryption key handling: auto-generate if not provided, update README and tests --- README.md | 13 ++-- server/internal/app/config.go | 8 ++- server/internal/app/config_test.go | 9 --- server/internal/app/init.go | 17 +++++- server/internal/secrets/jwt_key.go | 79 ++++++++++++++++++++++--- server/internal/secrets/jwt_key_test.go | 76 +++++++++++++++++++++++- 6 files changed, 170 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e185d4f..1567e01 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ Lemma can be configured using environment variables. Here are the available conf - `LEMMA_ADMIN_EMAIL`: Email address for the admin account - `LEMMA_ADMIN_PASSWORD`: Password for the admin account -- `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data ### Optional Environment Variables @@ -39,21 +38,17 @@ Lemma can be configured using environment variables. Here are the available conf - `LEMMA_PORT`: Port to run the server on (default: "8080") - `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication - `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins +- `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/encryption_key` - `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/jwt_signing_key` - `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production) - `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100) - `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m) -### Generating Encryption Keys +### Security Keys -The encryption key must be a base64-encoded 32-byte value. You can generate a secure encryption key using OpenSSL: +Both the encryption key and JWT signing key are automatically generated on first startup if not provided via environment variables. The keys are stored in `{LEMMA_WORKDIR}/secrets/` with restrictive file permissions (0600). -```bash -# Generate a random 32-byte key and encode it as base64 -openssl rand -base64 32 -``` - -Store the generated key securely - it will be needed to decrypt any data encrypted by the application. If the key is lost or changed, previously encrypted data will become inaccessible. +**Important**: Back up the `secrets` directory! If these keys are lost, encrypted data will become inaccessible and all users will need to re-authenticate. ## Running the backend server diff --git a/server/internal/app/config.go b/server/internal/app/config.go index 3708935..fab59d4 100644 --- a/server/internal/app/config.go +++ b/server/internal/app/config.go @@ -51,9 +51,11 @@ func (c *Config) validate() error { return fmt.Errorf("LEMMA_ADMIN_EMAIL and LEMMA_ADMIN_PASSWORD must be set") } - // Validate encryption key - if err := secrets.ValidateKey(c.EncryptionKey); err != nil { - return fmt.Errorf("invalid LEMMA_ENCRYPTION_KEY: %w", err) + // Validate encryption key if provided (if not provided, it will be auto-generated) + if c.EncryptionKey != "" { + if err := secrets.ValidateKey(c.EncryptionKey); err != nil { + return fmt.Errorf("invalid LEMMA_ENCRYPTION_KEY: %w", err) + } } return nil diff --git a/server/internal/app/config_test.go b/server/internal/app/config_test.go index 3205a57..976a91f 100644 --- a/server/internal/app/config_test.go +++ b/server/internal/app/config_test.go @@ -179,15 +179,6 @@ func TestLoad(t *testing.T) { }, expectedError: "LEMMA_ADMIN_EMAIL and LEMMA_ADMIN_PASSWORD must be set", }, - { - name: "missing encryption key", - setupEnv: func(t *testing.T) { - cleanup() - setEnv(t, "LEMMA_ADMIN_EMAIL", "admin@example.com") - setEnv(t, "LEMMA_ADMIN_PASSWORD", "password123") - }, - expectedError: "invalid LEMMA_ENCRYPTION_KEY: encryption key is required", - }, { name: "invalid encryption key", setupEnv: func(t *testing.T) { diff --git a/server/internal/app/init.go b/server/internal/app/init.go index b14de7b..1d0bc08 100644 --- a/server/internal/app/init.go +++ b/server/internal/app/init.go @@ -19,7 +19,22 @@ import ( // initSecretsService initializes the secrets service func initSecretsService(cfg *Config) (secrets.Service, error) { logging.Debug("initializing secrets service") - secretsService, err := secrets.NewService(cfg.EncryptionKey) + + // Get or generate encryption key + encryptionKey := cfg.EncryptionKey + if encryptionKey == "" { + logging.Debug("no encryption key provided, loading/generating from file") + + // Load or generate key from file + secretsDir := cfg.WorkDir + "/secrets" + var err error + encryptionKey, err = secrets.EnsureEncryptionKey(secretsDir) + if err != nil { + return nil, fmt.Errorf("failed to ensure encryption key: %w", err) + } + } + + secretsService, err := secrets.NewService(encryptionKey) if err != nil { return nil, fmt.Errorf("failed to initialize secrets service: %w", err) } diff --git a/server/internal/secrets/jwt_key.go b/server/internal/secrets/jwt_key.go index 98f03f2..797c92d 100644 --- a/server/internal/secrets/jwt_key.go +++ b/server/internal/secrets/jwt_key.go @@ -9,12 +9,14 @@ import ( ) const ( - // JWTKeyBytes is the size of the JWT signing key in bytes (256 bits) - JWTKeyBytes = 32 + // KeyBytes is the size of keys in bytes (256 bits) + KeyBytes = 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 + // EncryptionKeyFile is the filename for the encryption key + EncryptionKeyFile = "encryption_key" + // KeyPerm is the file permission for secret keys (owner read/write only) + KeyPerm = 0600 ) // EnsureJWTSigningKey ensures a JWT signing key exists in the secrets directory. @@ -55,12 +57,12 @@ func EnsureJWTSigningKey(secretsDir string) (string, error) { } // Write the key to the file with restrictive permissions - if err := os.WriteFile(keyPath, []byte(key), JWTKeyPerm); err != nil { + if err := os.WriteFile(keyPath, []byte(key), KeyPerm); 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 { + if err := os.Chmod(keyPath, KeyPerm); err != nil { return "", fmt.Errorf("failed to set JWT signing key permissions: %w", err) } @@ -70,7 +72,70 @@ func EnsureJWTSigningKey(secretsDir string) (string, error) { // generateJWTSigningKey generates a cryptographically secure random signing key func generateJWTSigningKey() (string, error) { - keyBytes := make([]byte, JWTKeyBytes) + keyBytes := make([]byte, KeyBytes) + 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 +} + +// EnsureEncryptionKey ensures an encryption key exists in the secrets directory. +// If no key exists, it generates and stores a new one with restrictive permissions. +// Returns the base64-encoded encryption key. +func EnsureEncryptionKey(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, EncryptionKeyFile) + + // 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 encryption key: %w", err) + } + + key := string(keyBytes) + if key == "" { + return "", fmt.Errorf("encryption key file is empty") + } + + log.Debug("loaded existing encryption key from file") + return key, nil + } + + // Key file doesn't exist, generate a new key + log.Info("generating new encryption key") + key, err := generateEncryptionKey() + if err != nil { + return "", fmt.Errorf("failed to generate encryption key: %w", err) + } + + // Write the key to the file with restrictive permissions + if err := os.WriteFile(keyPath, []byte(key), KeyPerm); err != nil { + return "", fmt.Errorf("failed to write encryption key: %w", err) + } + + // Double-check permissions (some systems might ignore mode in WriteFile) + if err := os.Chmod(keyPath, KeyPerm); err != nil { + return "", fmt.Errorf("failed to set encryption key permissions: %w", err) + } + + log.Info("encryption key generated and stored", "path", keyPath) + return key, nil +} + +// generateEncryptionKey generates a cryptographically secure random encryption key +func generateEncryptionKey() (string, error) { + keyBytes := make([]byte, KeyBytes) if _, err := rand.Read(keyBytes); err != nil { return "", fmt.Errorf("failed to generate random bytes: %w", err) } diff --git a/server/internal/secrets/jwt_key_test.go b/server/internal/secrets/jwt_key_test.go index fb924af..ef802b2 100644 --- a/server/internal/secrets/jwt_key_test.go +++ b/server/internal/secrets/jwt_key_test.go @@ -34,8 +34,8 @@ func TestEnsureJWTSigningKey(t *testing.T) { } perm := info.Mode().Perm() - if perm != JWTKeyPerm { - t.Errorf("expected permissions %o, got %o", JWTKeyPerm, perm) + if perm != KeyPerm { + t.Errorf("expected permissions %o, got %o", KeyPerm, perm) } }) @@ -65,7 +65,7 @@ func TestEnsureJWTSigningKey(t *testing.T) { 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 { + if err := os.WriteFile(keyPath, []byte(""), KeyPerm); err != nil { t.Fatalf("failed to write empty file: %v", err) } @@ -76,6 +76,76 @@ func TestEnsureJWTSigningKey(t *testing.T) { }) } +func TestEnsureEncryptionKey(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 := EnsureEncryptionKey(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, EncryptionKeyFile) + 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 != KeyPerm { + t.Errorf("expected permissions %o, got %o", KeyPerm, perm) + } + }) + + t.Run("loads existing key", func(t *testing.T) { + // First call to generate the key + key1, err := EnsureEncryptionKey(secretsDir) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Second call should load the same key + key2, err := EnsureEncryptionKey(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_encryption_test") + keyPath := filepath.Join(emptyDir, EncryptionKeyFile) + + // 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(""), KeyPerm); err != nil { + t.Fatalf("failed to write empty file: %v", err) + } + + _, err := EnsureEncryptionKey(emptyDir) + if err == nil { + t.Error("expected error for empty key file") + } + }) +} + func TestGenerateJWTSigningKey(t *testing.T) { key, err := generateJWTSigningKey() if err != nil { From a904a0d1a363016595bfda90d97454f2981ec79b Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 11 Oct 2025 21:22:38 +0200 Subject: [PATCH 4/5] Improve admin credentials error messages with setup instructions --- server/internal/app/config.go | 8 +++++++- server/internal/app/config_test.go | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/server/internal/app/config.go b/server/internal/app/config.go index fab59d4..a5e4010 100644 --- a/server/internal/app/config.go +++ b/server/internal/app/config.go @@ -48,7 +48,13 @@ func DefaultConfig() *Config { // validate checks if the configuration is valid func (c *Config) validate() error { if c.AdminEmail == "" || c.AdminPassword == "" { - return fmt.Errorf("LEMMA_ADMIN_EMAIL and LEMMA_ADMIN_PASSWORD must be set") + return fmt.Errorf(`admin credentials not configured + +To get started, set these environment variables: + export LEMMA_ADMIN_EMAIL="admin@example.com" + export LEMMA_ADMIN_PASSWORD="your-secure-password" + +Then start the server again.`) } // Validate encryption key if provided (if not provided, it will be auto-generated) diff --git a/server/internal/app/config_test.go b/server/internal/app/config_test.go index 976a91f..2419b8c 100644 --- a/server/internal/app/config_test.go +++ b/server/internal/app/config_test.go @@ -168,7 +168,13 @@ func TestLoad(t *testing.T) { setEnv(t, "LEMMA_ADMIN_PASSWORD", "password123") setEnv(t, "LEMMA_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") }, - expectedError: "LEMMA_ADMIN_EMAIL and LEMMA_ADMIN_PASSWORD must be set", + expectedError: `admin credentials not configured + +To get started, set these environment variables: + export LEMMA_ADMIN_EMAIL="admin@example.com" + export LEMMA_ADMIN_PASSWORD="your-secure-password" + +Then start the server again.`, }, { name: "missing admin password", @@ -177,7 +183,13 @@ func TestLoad(t *testing.T) { setEnv(t, "LEMMA_ADMIN_EMAIL", "admin@example.com") setEnv(t, "LEMMA_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") }, - expectedError: "LEMMA_ADMIN_EMAIL and LEMMA_ADMIN_PASSWORD must be set", + expectedError: `admin credentials not configured + +To get started, set these environment variables: + export LEMMA_ADMIN_EMAIL="admin@example.com" + export LEMMA_ADMIN_PASSWORD="your-secure-password" + +Then start the server again.`, }, { name: "invalid encryption key", From e5ad02df6d1080d4a89d77596bc96c8ced2a3356 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 11 Oct 2025 21:27:54 +0200 Subject: [PATCH 5/5] Update launch configurations --- .vscode/launch.json | 26 ++++++++++++++++++++++++-- app/package-lock.json | 6 +++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 451252d..b1c65db 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,13 +2,35 @@ "version": "0.2.0", "configurations": [ { - "name": "Launch Lemma Server", + "name": "Launch Backend", "type": "go", "request": "launch", "mode": "auto", "program": "${workspaceFolder}/server/cmd/server/main.go", "cwd": "${workspaceFolder}", - "envFile": "${workspaceFolder}/server/.env" + "envFile": "${workspaceFolder}/.env.local" + }, + { + "name": "Launch Frontend", + "type": "node", + "request": "launch", + "runtimeExecutable": "npm", + "runtimeArgs": ["start"], + "cwd": "${workspaceFolder}/app", + "console": "integratedTerminal", + "envFile": "${workspaceFolder}/.env.local" + } + ], + "compounds": [ + { + "name": "Launch Backend + Frontend", + "configurations": ["Launch Backend", "Launch Frontend"], + "presentation": { + "hidden": false, + "group": "", + "order": 1 + }, + "stopAll": true } ] } diff --git a/app/package-lock.json b/app/package-lock.json index 5229e1c..cafb15a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -3514,9 +3514,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001702", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", - "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", + "version": "1.0.30001749", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", + "integrity": "sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==", "dev": true, "funding": [ {