diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index 17528865..dbd445e2 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -6,11 +6,30 @@ import ( "log/slog" "net/url" "os" + "strings" "github.com/caarlos0/env/v11" _ "github.com/joho/godotenv/autoload" ) +func resolveStringOrFile(directValue string, filePath string, varName string, trim bool) (string, error) { + if directValue != "" { + return directValue, nil + } + if filePath != "" { + content, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to read secret '%s' from file '%s': %w", varName, filePath, err) + } + + if trim { + return strings.TrimSpace(string(content)), nil + } + return string(content), nil + } + return "", nil +} + type DbProvider string const ( @@ -28,29 +47,31 @@ const ( ) type EnvConfigSchema struct { - AppEnv string `env:"APP_ENV"` - AppURL string `env:"APP_URL"` - DbProvider DbProvider `env:"DB_PROVIDER"` - DbConnectionString string `env:"DB_CONNECTION_STRING"` - UploadPath string `env:"UPLOAD_PATH"` - KeysPath string `env:"KEYS_PATH"` - KeysStorage string `env:"KEYS_STORAGE"` - EncryptionKey string `env:"ENCRYPTION_KEY"` - EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"` - Port string `env:"PORT"` - Host string `env:"HOST"` - UnixSocket string `env:"UNIX_SOCKET"` - UnixSocketMode string `env:"UNIX_SOCKET_MODE"` - MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` - GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` - GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` - LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"` - UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` - MetricsEnabled bool `env:"METRICS_ENABLED"` - TracingEnabled bool `env:"TRACING_ENABLED"` - LogJSON bool `env:"LOG_JSON"` - TrustProxy bool `env:"TRUST_PROXY"` - AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` + AppEnv string `env:"APP_ENV"` + AppURL string `env:"APP_URL"` + DbProvider DbProvider `env:"DB_PROVIDER"` + DbConnectionString string `env:"DB_CONNECTION_STRING"` + DbConnectionStringFile string `env:"DB_CONNECTION_STRING_FILE"` + UploadPath string `env:"UPLOAD_PATH"` + KeysPath string `env:"KEYS_PATH"` + KeysStorage string `env:"KEYS_STORAGE"` + EncryptionKey string `env:"ENCRYPTION_KEY"` + EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"` + Port string `env:"PORT"` + Host string `env:"HOST"` + UnixSocket string `env:"UNIX_SOCKET"` + UnixSocketMode string `env:"UNIX_SOCKET_MODE"` + MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` + MaxMindLicenseKeyFile string `env:"MAXMIND_LICENSE_KEY_FILE"` + GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` + GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` + LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"` + UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` + MetricsEnabled bool `env:"METRICS_ENABLED"` + TracingEnabled bool `env:"TRACING_ENABLED"` + LogJSON bool `env:"LOG_JSON"` + TrustProxy bool `env:"TRUST_PROXY"` + AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` } var EnvConfig = defaultConfig() @@ -95,6 +116,29 @@ func parseEnvConfig() error { return fmt.Errorf("error parsing env config: %w", err) } + // Resolve string/file environment variables + EnvConfig.DbConnectionString, err = resolveStringOrFile( + EnvConfig.DbConnectionString, + EnvConfig.DbConnectionStringFile, + "DB_CONNECTION_STRING", + true, + ) + if err != nil { + return err + } + EnvConfig.DbConnectionStringFile = "" + + EnvConfig.MaxMindLicenseKey, err = resolveStringOrFile( + EnvConfig.MaxMindLicenseKey, + EnvConfig.MaxMindLicenseKeyFile, + "MAXMIND_LICENSE_KEY", + true, + ) + if err != nil { + return err + } + EnvConfig.MaxMindLicenseKeyFile = "" + // Validate the environment variables switch EnvConfig.DbProvider { case DbProviderSqlite: @@ -122,10 +166,23 @@ func parseEnvConfig() error { case "": EnvConfig.KeysStorage = "file" case "database": - // If KeysStorage is "database", a key must be specified - if EnvConfig.EncryptionKey == "" && EnvConfig.EncryptionKeyFile == "" { + // Resolve encryption key using the same pattern + encryptionKey, err := resolveStringOrFile( + EnvConfig.EncryptionKey, + EnvConfig.EncryptionKeyFile, + "ENCRYPTION_KEY", + // Do not trim spaces because the file should be interpreted as binary + false, + ) + if err != nil { + return err + } + if encryptionKey == "" { return errors.New("ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty when KEYS_STORAGE is database") } + // Update the config with resolved value + EnvConfig.EncryptionKey = encryptionKey + EnvConfig.EncryptionKeyFile = "" case "file": // All good, these are valid values default: diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index b6c26f20..deba901e 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -178,7 +178,7 @@ type AppConfigKeyNotFoundError struct { } func (e AppConfigKeyNotFoundError) Error() string { - return fmt.Sprintf("cannot find config key '%s'", e.field) + return "cannot find config key '" + e.field + "'" } func (e AppConfigKeyNotFoundError) Is(target error) bool { @@ -192,7 +192,7 @@ type AppConfigInternalForbiddenError struct { } func (e AppConfigInternalForbiddenError) Error() string { - return fmt.Sprintf("field '%s' is internal and can't be updated", e.field) + return "field '" + e.field + "' is internal and can't be updated" } func (e AppConfigInternalForbiddenError) Is(target error) bool { diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index 81422e44..2846bb5d 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -7,7 +7,6 @@ import ( "mime/multipart" "os" "reflect" - "slices" "strings" "sync/atomic" "time" @@ -412,12 +411,10 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) field := rt.Field(i) // Get the key and internal tag values - tagValue := strings.Split(field.Tag.Get("key"), ",") - key := tagValue[0] - isInternal := slices.Contains(tagValue, "internal") + key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",") // Internal fields are loaded from the database as they can't be set from the environment - if isInternal { + if attrs == "internal" { var value string err := tx.WithContext(ctx). Model(&model.AppConfigVariable{}). @@ -436,6 +433,20 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) value, ok := os.LookupEnv(envVarName) if ok { rv.Field(i).FieldByName("Value").SetString(value) + continue + } + + // If it's sensitive, we also allow reading from file + if attrs == "sensitive" { + fileName := os.Getenv(envVarName + "_FILE") + if fileName != "" { + b, err := os.ReadFile(fileName) + if err != nil { + return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err) + } + rv.Field(i).FieldByName("Value").SetString(string(b)) + continue + } } } diff --git a/backend/internal/utils/jwk/utils.go b/backend/internal/utils/jwk/utils.go index 815d5734..5debfec4 100644 --- a/backend/internal/utils/jwk/utils.go +++ b/backend/internal/utils/jwk/utils.go @@ -15,7 +15,6 @@ import ( "fmt" "hash" "io" - "os" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" @@ -47,26 +46,15 @@ func EncodeJWKBytes(key jwk.Key) ([]byte, error) { // LoadKeyEncryptionKey loads the key encryption key for JWKs func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) { - // Try getting the key from the env var as string - kekInput := []byte(envConfig.EncryptionKey) - - // If there's nothing in the env, try loading from file - if len(kekInput) == 0 && envConfig.EncryptionKeyFile != "" { - kekInput, err = os.ReadFile(envConfig.EncryptionKeyFile) - if err != nil { - return nil, fmt.Errorf("failed to read key file '%s': %w", envConfig.EncryptionKeyFile, err) - } - } - - // If there's still no key, return - if len(kekInput) == 0 { + // If there's no key, return + if len(envConfig.EncryptionKey) == 0 { return nil, nil } // We need a 256-bit key for encryption with AES-GCM-256 // We use HMAC with SHA3-256 here to derive the key from the one passed as input // The key is tied to a specific instance of Pocket ID - h := hmac.New(func() hash.Hash { return sha3.New256() }, kekInput) + h := hmac.New(func() hash.Hash { return sha3.New256() }, []byte(envConfig.EncryptionKey)) fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek") kek = h.Sum(nil)