mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 15:44:21 +00:00
Merge pull request #59 from lordmathis/fix/signing-key
Generate signing and encryption key to a file
This commit is contained in:
26
.vscode/launch.json
vendored
26
.vscode/launch.json
vendored
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
15
README.md
15
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_JWT_SIGNING_KEY`: Key used for signing JWT tokens
|
||||
- `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
|
||||
|
||||
|
||||
6
app/package-lock.json
generated
6
app/package-lock.json
generated
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -48,13 +48,21 @@ 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
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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,16 +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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -52,11 +67,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,12 +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)
|
||||
EnsureJWTSecret() (string, error)
|
||||
GetSystemSetting(key string) (string, error)
|
||||
SetSystemSetting(key, value string) error
|
||||
}
|
||||
|
||||
type StructScanner interface {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -26,7 +26,6 @@ func TestMigrate(t *testing.T) {
|
||||
"users",
|
||||
"workspaces",
|
||||
"sessions",
|
||||
"system_settings",
|
||||
"schema_migrations",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"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,83 +11,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
|
||||
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{}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,126 +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("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{
|
||||
|
||||
147
server/internal/secrets/jwt_key.go
Normal file
147
server/internal/secrets/jwt_key.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
// 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"
|
||||
// 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.
|
||||
// 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), 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, KeyPerm); 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, 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)
|
||||
}
|
||||
|
||||
// Encode to base64 for easy storage and handling
|
||||
key := base64.StdEncoding.EncodeToString(keyBytes)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
169
server/internal/secrets/jwt_key_test.go
Normal file
169
server/internal/secrets/jwt_key_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
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 != 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 := 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(""), KeyPerm); 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 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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user