fix(backend,helm): stop shipping a shared JWT signing key in docker-compose (#135)

* fix(auth): stop shipping a shared JWT signing key in docker-compose

* fix(auth): add persistent random generated jwt key

* fix coverage

* refactor(auth): drop unreachable error branches in JWT key generation
This commit is contained in:
Noste
2026-10-14 11:34:25 +02:00
committed by GitHub
parent 896bc3a0f5
commit a2b32a13cf
8 changed files with 171 additions and 14 deletions
+6 -1
View File
@@ -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
+9 -1
View File
@@ -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)
}
+33
View File
@@ -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)
+40
View File
@@ -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 {
+61
View File
@@ -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 {
+7 -5
View File
@@ -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
+7
View File
@@ -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)
+8 -7
View File
@@ -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: