diff --git a/Dockerfile b/Dockerfile index 81346cd..d7d7b3f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,11 +46,16 @@ RUN apk update && apk upgrade --no-cache && \ apk --no-cache add ca-certificates wget RUN addgroup -g 1000 garageui && \ - adduser -D -u 1000 -G garageui garageui + adduser -D -u 1000 -G garageui garageui && \ + mkdir -p /app/data && chown garageui:garageui /app/data COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui . COPY --from=frontend-builder --chown=garageui:garageui /app/frontend/dist ./frontend/dist +# Without a configured key, one is generated here on first start and reused. +# Mount a volume on /app/data to keep it when the container is recreated. +ENV GARAGE_UI_AUTH_JWT_PRIVATE_KEY_PATH=/app/data/jwt-key.pem + USER garageui EXPOSE 8080 diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 1b1531d..f4f3df9 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -43,7 +43,15 @@ type UserInfo struct { // NewAuthService creates a new authentication service func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*Service, error) { - jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey) + keyPEM := authCfg.JWTPrivKey + if keyPEM == "" && authCfg.JWTPrivKeyPath != "" { + var err error + if keyPEM, err = loadOrCreateKeyFile(authCfg.JWTPrivKeyPath); err != nil { + logger.Warn().Err(err).Str("path", authCfg.JWTPrivKeyPath).Msg("Cannot load or save the JWT signing key, using a random key; sessions will not survive restarts") + } + } + + jwtService, err := NewJWTServiceWithKey(keyPEM) if err != nil { return nil, fmt.Errorf("failed to initialize JWT service: %w", err) } diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go index 944d57b..89c6982 100644 --- a/backend/internal/auth/auth_test.go +++ b/backend/internal/auth/auth_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -220,6 +222,37 @@ func TestNewAuthService_OIDCDisabled_DoesNotInitProvider(t *testing.T) { } } +func TestNewAuthService_JWTKeyPath_SavesAndReusesKey(t *testing.T) { + cfg := &config.AuthConfig{JWTPrivKeyPath: filepath.Join(t.TempDir(), "jwt-key.pem")} + + first, err := NewAuthService(cfg, &config.ServerConfig{}) + if err != nil { + t.Fatalf("first NewAuthService: %v", err) + } + second, err := NewAuthService(cfg, &config.ServerConfig{}) + if err != nil { + t.Fatalf("second NewAuthService: %v", err) + } + if !first.jwtService.publicKey.Equal(second.jwtService.publicKey) { + t.Error("second start used a different key instead of the saved one") + } +} + +func TestNewAuthService_JWTKeyPathUnusable_FallsBackToRandomKey(t *testing.T) { + blocker := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(blocker, nil, 0o600); err != nil { + t.Fatal(err) + } + + svc, err := NewAuthService(&config.AuthConfig{JWTPrivKeyPath: filepath.Join(blocker, "jwt-key.pem")}, &config.ServerConfig{}) + if err != nil { + t.Fatalf("NewAuthService should fall back, got: %v", err) + } + if svc.jwtService.privateKey == nil { + t.Error("no signing key after fallback") + } +} + func TestNewAuthService_OIDCEnabled_DiscoversProvider(t *testing.T) { disco := newDiscoveryServer(t) diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 904affe..5668e9e 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -6,10 +6,15 @@ import ( "crypto/x509" "encoding/base64" "encoding/pem" + "errors" "fmt" + "io/fs" + "os" "sync" "time" + "Noooste/garage-ui/pkg/logger" + "github.com/golang-jwt/jwt/v5" ) @@ -40,6 +45,10 @@ type SessionClaims struct { jwt.RegisteredClaims } +// publishedComposeKey shipped in docker-compose.yml up to 0.13.0 (issue #132). +// It is public, so it is refused even when an operator configures it. +var publishedComposeKey, _ = parseEd25519PrivateKeyFromPEM("-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS\n-----END PRIVATE KEY-----\n") + func NewJWTService() (*JWTService, error) { return NewJWTServiceWithKey("") } @@ -55,6 +64,13 @@ func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) { if err != nil { return nil, fmt.Errorf("failed to parse Ed25519 private key: %w", err) } + if privateKey.Equal(publishedComposeKey) { + logger.Warn().Msg("GARAGE_UI_AUTH_JWT_PRIVATE_KEY is the public example key from docker-compose.yml, anyone could forge sessions with it. Ignoring it and using a random key; sessions will not survive restarts until you set your own key") + privateKey = nil + } + } + + if privateKey != nil { publicKey = privateKey.Public().(ed25519.PublicKey) } else { // Generate a new Ed25519 key pair if no key is provided @@ -73,6 +89,30 @@ func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) { }, nil } +// loadOrCreateKeyFile returns the PEM key stored at path. On first use it +// generates a key and saves it there, so sessions survive restarts. The +// parent directory must already exist. +func loadOrCreateKeyFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err == nil { + return string(data), nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + + seed := make([]byte, ed25519.SeedSize) + rand.Read(seed) // never fails since Go 1.24 + // Marshalling an Ed25519 key cannot fail. + der, _ := x509.MarshalPKCS8PrivateKey(ed25519.NewKeyFromSeed(seed)) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + if err := os.WriteFile(path, keyPEM, 0o600); err != nil { + return "", err + } + logger.Info().Str("path", path).Msg("Generated a new JWT signing key") + return string(keyPEM), nil +} + func parseEd25519PrivateKeyFromPEM(privateKeyPEM string) (ed25519.PrivateKey, error) { block, _ := pem.Decode([]byte(privateKeyPEM)) if block == nil { diff --git a/backend/internal/auth/jwt_test.go b/backend/internal/auth/jwt_test.go index 38d5851..73af88c 100644 --- a/backend/internal/auth/jwt_test.go +++ b/backend/internal/auth/jwt_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/pem" "errors" + "path/filepath" "strings" "testing" "time" @@ -118,6 +119,66 @@ func TestNewJWTService_AutoGeneratesKeyPair(t *testing.T) { } } +// The key that shipped in docker-compose.yml (issue #132) is public, so a +// token an attacker signs with it must not validate. +func TestNewJWTServiceWithKey_RejectsPublishedComposeKey(t *testing.T) { + const published = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS\n-----END PRIVATE KEY-----\n" + svc, err := NewJWTServiceWithKey(published) + if err != nil { + t.Fatalf("NewJWTServiceWithKey: %v", err) + } + attackerKey, err := parseEd25519PrivateKeyFromPEM(published) + if err != nil { + t.Fatalf("parse published key: %v", err) + } + forged, err := jwt.NewWithClaims(jwt.SigningMethodEdDSA, SessionClaims{ + Username: "forged-admin", + AuthMethod: "admin", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + }).SignedString(attackerKey) + if err != nil { + t.Fatalf("sign forged token: %v", err) + } + if _, err := svc.ValidateToken(forged); err == nil { + t.Fatal("token signed with the published compose key was accepted") + } +} + +func TestLoadOrCreateKeyFile_GeneratesOnceThenReuses(t *testing.T) { + path := filepath.Join(t.TempDir(), "jwt-key.pem") + + first, err := loadOrCreateKeyFile(path) + if err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := parseEd25519PrivateKeyFromPEM(first); err != nil { + t.Fatalf("generated key does not parse: %v", err) + } + second, err := loadOrCreateKeyFile(path) + if err != nil { + t.Fatalf("second call: %v", err) + } + if first != second { + t.Fatal("second call generated a new key instead of reusing the saved one") + } +} + +func TestLoadOrCreateKeyFile_MissingParentDirFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing", "jwt-key.pem") + if _, err := loadOrCreateKeyFile(path); err == nil { + t.Fatal("expected an error when the parent directory does not exist") + } +} + +func TestLoadOrCreateKeyFile_UnreadablePathFails(t *testing.T) { + // A directory exists but cannot be read as a key, so nothing is generated. + if _, err := loadOrCreateKeyFile(t.TempDir()); err == nil { + t.Fatal("expected an error for a directory path") + } +} + func TestNewJWTServiceWithKey_EmptyStringAutoGenerates(t *testing.T) { svc, err := NewJWTServiceWithKey("") if err != nil { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 12cbea1..878cbca 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -50,11 +50,12 @@ type GarageConfig struct { // AuthConfig contains authentication configuration type AuthConfig struct { - Admin AdminAuthConfig `mapstructure:"admin"` - OIDC OIDCConfig `mapstructure:"oidc"` - Token TokenAuthConfig `mapstructure:"token"` - JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes) - MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth + Admin AdminAuthConfig `mapstructure:"admin"` + OIDC OIDCConfig `mapstructure:"oidc"` + Token TokenAuthConfig `mapstructure:"token"` + JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes) + JWTPrivKeyPath string `mapstructure:"jwt_private_key_path"` // Where to save a generated key when JWTPrivKey is empty; empty means a new key per start + MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth } // AdminAuthConfig contains admin authentication settings @@ -311,6 +312,7 @@ func bindEnvVars() { viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME") viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD") viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY") + viper.BindEnv("auth.jwt_private_key_path", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY_PATH") viper.BindEnv("auth.metrics_public", "GARAGE_UI_AUTH_METRICS_PUBLIC") // Token auth config diff --git a/config.example.yaml b/config.example.yaml index 047d134..8a250d9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -57,6 +57,13 @@ auth: # The key is a 64-byte Ed25519 private key jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key + # When jwt_private_key is empty, generate a key on first start, save it to this + # file and reuse it afterwards, so tokens survive restarts. Empty keeps the + # per-start key. The directory must exist. The Docker image sets + # /app/data/jwt-key.pem. + # Env: GARAGE_UI_AUTH_JWT_PRIVATE_KEY_PATH + jwt_private_key_path: "" + # Expose Prometheus metrics at top-level /metrics WITHOUT authentication. # Needed for Prometheus to scrape when auth (admin/token/oidc) is enabled. # WARNING: exposes operational cluster telemetry (no object data or secrets) diff --git a/docker-compose.yml b/docker-compose.yml index 5bf37a5..98af7c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: restart: unless-stopped volumes: - ./config.yaml:/app/config.yaml + # Holds the JWT signing key generated on first start + - garage-ui-data:/app/data ports: - "127.0.0.1:8080:8080" depends_on: @@ -34,10 +36,9 @@ services: GARAGE_UI_SERVER_PORT: "8080" GARAGE_UI_SERVER_ENVIRONMENT: "production" - # JWT Configuration - # IMPORTANT: Replace this with your own Ed25519 private key in production! - # Generate with: openssl genpkey -algorithm ED25519 - GARAGE_UI_AUTH_JWT_PRIVATE_KEY: | - -----BEGIN PRIVATE KEY----- - MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS - -----END PRIVATE KEY----- + # JWT signing key: generated on first start and saved in the + # garage-ui-data volume. To use your own instead, mount it and set + # GARAGE_UI_AUTH_JWT_PRIVATE_KEY_FILE: /path/to/jwt-key.pem + +volumes: + garage-ui-data: