diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index 3dfc2a68ef..4436abe956 100644 --- a/.github/workflows/ai-workspace-pr-check.yml +++ b/.github/workflows/ai-workspace-pr-check.yml @@ -17,7 +17,8 @@ jobs: pr-check: runs-on: ubuntu-24.04 env: - AUTH_JWT_SECRET_KEY: ai-workspace-pr-check-secret-key + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + AUTH_JWT_SECRET_KEY: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml index 23b65d9296..d4dbb4ab05 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -40,6 +40,8 @@ services: context: ../../portals/developer-portal dockerfile: Dockerfile container_name: devportal + environment: + - APIP_DP_PLATFORMAPI_JWTSECRET:${AUTH_JWT_SECRET_KEY:-} ports: - "3001:3001" volumes: @@ -73,7 +75,8 @@ services: - DATABASE_MAX_IDLE_CONNS=10 - DATABASE_CONN_MAX_LIFETIME=300 - DATABASE_EXECUTE_SCHEMA_DDL=true - - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=${DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY} + - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} depends_on: postgres: condition: service_healthy diff --git a/docs/ai-workspace/configuration.md b/docs/ai-workspace/configuration.md index ea0888d138..8aa615de21 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -92,6 +92,7 @@ Sensitive values (JWT signing key, database password) must be passed as environm | Platform API env variable | Description | |--------------------------|-------------| | `AUTH_JWT_SECRET_KEY` | JWT signing key (required when `auth.jwt.enabled = true`) | +| `ENCRYPTION_KEY` | 32-byte key (64 hex / base64) — encrypts secrets & subscription tokens (required) | | `DATABASE_PASSWORD` | Database password | ## Environment Variable Override diff --git a/docs/ai-workspace/features/secrets-management.md b/docs/ai-workspace/features/secrets-management.md index ecc443d24d..17b61e4ae1 100644 --- a/docs/ai-workspace/features/secrets-management.md +++ b/docs/ai-workspace/features/secrets-management.md @@ -337,7 +337,7 @@ The following environment variable controls encryption for the Platform API: | Env Var | Description | |---------|-------------| -| `PLATFORM_SECRET_ENCRYPTION_KEY` | 32-byte AES-256 key as 64 hex characters or base64. If unset, a random ephemeral key is auto-generated at startup — secrets stored in that session will be unreadable after a restart. | +| `ENCRYPTION_KEY` | 32-byte AES-256 key as 64 hex characters or base64. **Required in every mode and never auto-generated** — the Platform API fails to start if it is missing or malformed. | Generate a stable key with: @@ -355,7 +355,7 @@ openssl rand -hex 32 Then copy the output value into your `.env` file: ```sh -PLATFORM_SECRET_ENCRYPTION_KEY=a3f1e2d4b5c6... +ENCRYPTION_KEY=a3f1e2d4b5c6... ``` -> **Warning:** Always set a stable `PLATFORM_SECRET_ENCRYPTION_KEY` in any environment where secrets must persist across restarts or across multiple replicas. An ephemeral auto-generated key will make existing encrypted secrets unreadable after a restart. +> **Warning:** Use the **same** stable `ENCRYPTION_KEY` across restarts and across all replicas. Changing it makes existing encrypted secrets unreadable. diff --git a/platform-api/README.md b/platform-api/README.md index e5922f0d2b..4154df88dc 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -241,21 +241,19 @@ AUTH_IDP_ENABLED=true → IDP mode (JWKS-based verification) ``` > **Demo mode (`APIP_DEMO_MODE`).** Defaults to `true`; an explicit `false`/`0` opts into -> production-grade startup checks. With demo mode off, the server will not fall back to an -> ephemeral secret encryption key (set `PLATFORM_SECRET_ENCRYPTION_KEY` or -> `DATABASE_ENCRYPTION_KEY`) and warns loudly if `AUTH_JWT_SKIP_VALIDATION=true`. +> production-grade startup checks. Note that `ENCRYPTION_KEY` and `AUTH_JWT_SECRET_KEY` are **required**. --- #### Local JWT Mode (default) -The server validates HMAC-signed tokens using `AUTH_JWT_SECRET_KEY`. Set `AUTH_JWT_SKIP_VALIDATION=true` only in local development environments where you do not have a token issuer available — all bearer values will be accepted without any signature check. +The server signs and validates HMAC login tokens using `AUTH_JWT_SECRET_KEY` — a 32-byte key (64 hex chars or base64). Set `AUTH_JWT_SKIP_VALIDATION=true` only in local development environments where you do not have a token issuer available — all bearer values will be accepted without any signature check. -| Variable | Default | Description | -|---|---|---| -| `AUTH_JWT_SECRET_KEY` | `your-secret-key-change-in-production` | HMAC signing key for token verification | -| `AUTH_JWT_ISSUER` | `platform-api` | Expected `iss` claim value | -| `AUTH_JWT_SKIP_VALIDATION` | `false` | Skip signature verification — **development only** | +| Variable | Default | Description | +|---|---|---------------------------------------------------------------------| +| `AUTH_JWT_SECRET_KEY` | _(empty)_ | HMAC key for signing/verifying login JWTs — 32-byte value (64 hex or base64; `openssl rand -hex 32`) | +| `AUTH_JWT_ISSUER` | `platform-api` | Expected `iss` claim value | +| `AUTH_JWT_SKIP_VALIDATION` | `false` | Skip signature verification — **development only** | | `DEV_MODE` | `false` | Suppresses the startup warning when `AUTH_JWT_SKIP_VALIDATION=true` | Local development with no token issuer: @@ -369,7 +367,16 @@ In **IDP mode with `AUTH_IDP_VALIDATION_MODE=role`**, IDP roles are resolved fro | `DATABASE_PASSWORD` | _(empty)_ | Postgres password | | `DATABASE_SSL_MODE` | `disable` | Postgres SSL mode (`disable`, `require`, `verify-full`) | | `DATABASE_EXECUTE_SCHEMA_DDL` | `true` | Set to `false` when the DB user lacks DDL privileges | -| `DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY` | _(empty)_ | 32-byte key (64 hex or 44 base64 chars) for AES-256-GCM token encryption. | + +--- + +### Encryption + +`ENCRYPTION_KEY` protects all at-rest encryption (secrets, subscription tokens, WebSub HMAC secrets). It is **never auto-generated** — the operator must provide it. + +| Variable | Default | Description | +|---|---|---| +| `ENCRYPTION_KEY` | _(empty)_ | **Required.** 32-byte AES-256 key as 64 hex chars or base64 (32 bytes). Generate with `openssl rand -hex 32`. Startup fails if missing or malformed. | --- diff --git a/platform-api/config/config.go b/platform-api/config/config.go index a089b808ab..80745f3be7 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -18,14 +18,11 @@ package config import ( - "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" - "errors" "fmt" "log/slog" - "os" - "path/filepath" "reflect" "strings" "sync" @@ -85,6 +82,8 @@ type Server struct { OpenAPISpecPath string `koanf:"openapi_spec_path"` LLMTemplateDefinitionsPath string `koanf:"llm_template_definitions_path"` + EncryptionKey string `koanf:"encryption_key"` + Database Database `koanf:"database"` Auth Auth `koanf:"auth"` WebSocket WebSocket `koanf:"websocket"` @@ -214,16 +213,6 @@ type Database struct { MaxOpenConns int `koanf:"max_open_conns"` MaxIdleConns int `koanf:"max_idle_conns"` ConnMaxLifetime int `koanf:"conn_max_lifetime"` - - EncryptionKey string `koanf:"encryption_key"` - SubscriptionTokenEncryptionKey string `koanf:"subscription_token_encryption_key"` - SecretEncryptionKey string `koanf:"secret_encryption_key"` - // SecretEncryptionKeyFile is the path to a 32-byte binary key file used for secret encryption. - // Honoured in both demo and non-demo mode when neither SecretEncryptionKey nor - // EncryptionKey is set. In demo mode the file is auto-generated on first startup and - // reused on subsequent restarts; in non-demo mode the file must already exist (a missing - // or unreadable file is fatal). Matches the gateway controller key-management pattern. - SecretEncryptionKeyFile string `koanf:"secret_encryption_key_file"` } // DefaultDevPortal holds default DevPortal configuration for new organizations. @@ -369,171 +358,40 @@ func LoadConfig(configPath string) (*Server, error) { return nil, err } - if cfg.Auth.JWT.Enabled && cfg.Auth.JWT.SecretKey == "" { - if !demoMode() { - return nil, fmt.Errorf( - "AUTH_JWT_SECRET_KEY must be configured when APIP_DEMO_MODE=false and JWT authentication is enabled; " + - "generate a secret with: openssl rand -hex 32", - ) + if cfg.Auth.JWT.Enabled { + if cfg.Auth.JWT.SecretKey == "" { + return nil, fmt.Errorf("AUTH_JWT_SECRET_KEY is required when JWT authentication is enabled; " + + "generate one with: openssl rand -hex 32") } - key, err := generateRandomSecret() - if err != nil { - return nil, fmt.Errorf("failed to generate JWT secret key: %w", err) - } - cfg.Auth.JWT.SecretKey = key - slog.Warn("AUTH_JWT_SECRET_KEY not set — generated an ephemeral demo key (restart will invalidate all sessions)", - slog.String("AUTH_JWT_SECRET_KEY", key)) - } - - // Resolve the secret key file path: explicit config → default alongside the DB file. - if cfg.Database.SecretEncryptionKeyFile == "" && cfg.Database.Path != "" { - cfg.Database.SecretEncryptionKeyFile = filepath.Join(filepath.Dir(cfg.Database.Path), "secret-encryption.key") - } - - // SecretEncryptionKey is optional when the shared DATABASE_ENCRYPTION_KEY is configured; - // server.go resolves the final key via: SecretEncryptionKey → EncryptionKey. - // Only fail (or auto-generate in demo mode) when no key source is available at all. - if cfg.Database.SecretEncryptionKey == "" && cfg.Database.EncryptionKey == "" { - if cfg.Database.SecretEncryptionKeyFile != "" { - demoMode := strings.ToLower(strings.TrimSpace(os.Getenv("APIP_DEMO_MODE"))) - isDemoMode := demoMode != "false" && demoMode != "0" - if isDemoMode { - // Demo mode: auto-generate the key file on first start, reload on subsequent starts. - hexKey, err := loadOrGenerateSecretKeyFile(cfg.Database.SecretEncryptionKeyFile) - if err == nil { - cfg.Database.SecretEncryptionKey = hexKey - } else { - slog.Warn("APIP_DEMO_MODE: could not initialise secret key file, falling back to ephemeral key", - slog.String("path", cfg.Database.SecretEncryptionKeyFile), slog.Any("err", err)) - } - } else { - // Non-demo mode: the key file must already exist — never auto-generate. - hexKey, err := loadSecretKeyFile(cfg.Database.SecretEncryptionKeyFile) - if err != nil { - return nil, fmt.Errorf("failed to load secret key file: %w", err) - } - cfg.Database.SecretEncryptionKey = hexKey - } + if !valid32ByteKey(cfg.Auth.JWT.SecretKey) { + return nil, fmt.Errorf("invalid AUTH_JWT_SECRET_KEY: must be 64 hex characters or " + + "base64 decoding to 32 bytes (generate one with: openssl rand -hex 32)") } } - if cfg.Database.SecretEncryptionKey == "" && cfg.Database.EncryptionKey == "" { - // APIP_DEMO_MODE defaults to enabled when unset; only an explicit - // "false"/"0" opts out and requires a configured encryption key. - demoMode := strings.ToLower(strings.TrimSpace(os.Getenv("APIP_DEMO_MODE"))) - if demoMode == "false" || demoMode == "0" { - return nil, fmt.Errorf("no encryption key configured for secrets management. " + - "Set PLATFORM_SECRET_ENCRYPTION_KEY (secret-specific), DATABASE_ENCRYPTION_KEY (shared), " + - "or DATABASE_SECRET_ENCRYPTION_KEY_FILE (key file). " + - "Generate one with: openssl rand -hex 32. " + - "To allow an ephemeral key in a single-node dev environment, set APIP_DEMO_MODE=true") - } - - // Demo mode with no usable key file — fall back to an ephemeral key. - // Secrets will not survive restarts. - key, err := generateRandomSecret() - if err != nil { - return nil, fmt.Errorf("failed to generate secret encryption key: %w", err) - } - cfg.Database.SecretEncryptionKey = key - slog.Warn("APIP_DEMO_MODE: using an ephemeral random key — encrypted secrets will be unreadable after restart. " + - "Set DATABASE_SECRET_ENCRYPTION_KEY_FILE, PLATFORM_SECRET_ENCRYPTION_KEY, or DATABASE_ENCRYPTION_KEY.") + if cfg.EncryptionKey == "" { + return nil, fmt.Errorf("ENCRYPTION_KEY is required; generate one with: openssl rand -hex 32") + } + if !valid32ByteKey(cfg.EncryptionKey) { + return nil, fmt.Errorf("invalid ENCRYPTION_KEY: must be 64 hex characters or " + + "base64 decoding to 32 bytes (generate one with: openssl rand -hex 32)") } return cfg, nil } -func generateRandomSecret() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err +// valid32ByteKey reports whether keyStr is a 32-byte key encoded as 64 hex characters +// or base64 decoding to 32 bytes — matching utils.DeriveEncryptionKey's acceptance. +func valid32ByteKey(keyStr string) bool { + if len(keyStr) == 64 { + if k, err := hex.DecodeString(keyStr); err == nil && len(k) == 32 { + return true + } } - return hex.EncodeToString(b), nil -} - -// demoMode reports whether APIP_DEMO_MODE is enabled. -// Defaults to true when the variable is unset. -func demoMode() bool { - v := strings.ToLower(strings.TrimSpace(os.Getenv("APIP_DEMO_MODE"))) - if v == "" { + if k, err := base64.StdEncoding.DecodeString(keyStr); err == nil && len(k) == 32 { return true } - return v == "true" || v == "1" -} - -const secretKeySize = 32 // AES-256 - -// loadOrGenerateSecretKeyFile loads a 32-byte binary key file from filePath, creating it -// (and any missing parent directories) on first run. This mirrors the gateway controller's -// KeyManager pattern: raw binary key file, 0600 permissions, validate size on load. -// Returns the key as a 64-char hex string for use with DeriveEncryptionKey. -// -// Concurrent first-time callers are safe: generateSecretKeyFile uses O_CREATE|O_EXCL so -// only one writer succeeds; others see os.ErrExist and fall through to loadSecretKeyFile. -func loadOrGenerateSecretKeyFile(filePath string) (string, error) { - err := generateSecretKeyFile(filePath) - switch { - case err == nil: - slog.Info("APIP_DEMO_MODE: generated and persisted secret encryption key — encrypted secrets will survive restarts", - slog.String("path", filePath), - slog.String("hint", "Set PLATFORM_SECRET_ENCRYPTION_KEY or DATABASE_ENCRYPTION_KEY for production or multi-replica deployments")) - case errors.Is(err, os.ErrExist): - // Another initializer already created the file — load the winner's key. - default: - return "", err - } - return loadSecretKeyFile(filePath) -} - -// generateSecretKeyFile creates parent directories and writes 32 cryptographically -// random bytes to filePath with permissions 0600. Uses O_CREATE|O_EXCL so concurrent -// first-time callers are safe: only one writer succeeds, others get os.ErrExist. -// Mirrors gateway-controller's generateKeyFile. -func generateSecretKeyFile(filePath string) error { - if err := os.MkdirAll(filepath.Dir(filePath), 0700); err != nil { - return fmt.Errorf("failed to create key directory: %w", err) - } - key := make([]byte, secretKeySize) - if _, err := rand.Read(key); err != nil { - return fmt.Errorf("failed to generate random key: %w", err) - } - f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) - if err != nil { - // Propagate os.ErrExist so the caller can distinguish "already created" from other errors. - return err - } - _, err = f.Write(key) - if closeErr := f.Close(); closeErr != nil && err == nil { - err = closeErr - } - if err != nil { - os.Remove(filePath) // best-effort cleanup of a partial write - return fmt.Errorf("failed to write key file %s: %w", filePath, err) - } - return nil -} - -// loadSecretKeyFile reads the key file, validates its size, warns if world-readable, -// and returns the key as a 64-char hex string. -func loadSecretKeyFile(filePath string) (string, error) { - info, err := os.Stat(filePath) - if err != nil { - return "", fmt.Errorf("failed to stat secret key file %s: %w", filePath, err) - } - if info.Mode().Perm()&0004 != 0 { - slog.Warn("Secret encryption key file is world-readable — consider restricting permissions to 0600", - slog.String("path", filePath), - slog.String("permissions", info.Mode().Perm().String())) - } - data, err := os.ReadFile(filePath) - if err != nil { - return "", fmt.Errorf("failed to read secret key file %s: %w", filePath, err) - } - if len(data) != secretKeySize { - return "", fmt.Errorf("secret key file %s has wrong size: expected %d bytes, got %d", filePath, secretKeySize, len(data)) - } - slog.Info("APIP_DEMO_MODE: loaded persisted secret encryption key", slog.String("path", filePath)) - return hex.EncodeToString(data), nil + return false } // envToKoanfKey maps a lowercased environment variable name to its koanf dot-notation key. @@ -557,6 +415,8 @@ func envToKoanfKey(s string) string { return "llm_template_definitions_path" case "enable_scope_validation": return "enable_scope_validation" + case "encryption_key": + return "encryption_key" // Database case "database_driver": @@ -581,14 +441,6 @@ func envToKoanfKey(s string) string { return "database.max_idle_conns" case "database_conn_max_lifetime": return "database.conn_max_lifetime" - case "database_encryption_key": - return "database.encryption_key" - case "database_subscription_token_encryption_key": - return "database.subscription_token_encryption_key" - case "platform_secret_encryption_key": - return "database.secret_encryption_key" - case "database_secret_encryption_key_file": - return "database.secret_encryption_key_file" // Auth case "auth_skip_paths": diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 10a9dbbdd2..7b29af97f5 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -24,6 +24,14 @@ log_level = "INFO" # DEBUG | INFO | WARN | ERROR log_format = "text" # text | json port = "9243" +# --------------------------------------------------------------------------- +# Encryption +# --------------------------------------------------------------------------- +# Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption +# (secrets, subscription tokens, WebSub HMAC secrets) +# Env: ENCRYPTION_KEY. Generate with: openssl rand -hex 32. +# encryption_key = "" + # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- @@ -44,8 +52,8 @@ driver = "sqlite3" # "sqlite3" or "postgres" # --------------------------------------------------------------------------- # JWT (local HMAC) — issues signed tokens after file-based login. -# secret_key is auto-generated at startup when not set (ephemeral — sessions reset on restart). -# Set AUTH_JWT_SECRET_KEY env var or secret_key here to persist sessions across restarts. +# secret_key is a 32-byte key (64 hex chars or base64; openssl rand -hex 32) +# Env: AUTH_JWT_SECRET_KEY. Generate with: openssl rand -hex 32. [auth.jwt] enabled = true issuer = "platform-api" diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index f17f3c182e..a44119ef7e 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -20,159 +20,96 @@ package config import ( "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TC-35: Missing PLATFORM_SECRET_ENCRYPTION_KEY with APIP_DEMO_MODE=true → -// server starts successfully with an auto-generated ephemeral key. -func TestLoadConfig_MissingSecretEncryptionKey_DemoMode_GeneratesEphemeralKey(t *testing.T) { - t.Setenv("APIP_DEMO_MODE", "true") - t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") - // Ensure the koanf env-var alias doesn't accidentally provide a value. - t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") - t.Setenv("DATABASE_ENCRYPTION_KEY", "") - t.Setenv("AUTH_JWT_SECRET_KEY", "") - - cfg, err := LoadConfig("") - require.NoError(t, err, "LoadConfig must succeed in DEMO_MODE even without a secret encryption key") - assert.NotEmpty(t, cfg.Database.SecretEncryptionKey, - "an ephemeral key must be generated when PLATFORM_SECRET_ENCRYPTION_KEY is absent in DEMO_MODE") -} - -// TC-35 (negative): Missing key WITHOUT demo mode → fatal error returned. -// JWT auth is disabled so the JWT-key check doesn't fire before the encryption-key check. -func TestLoadConfig_MissingSecretEncryptionKey_NonDemoMode_ReturnsError(t *testing.T) { - t.Setenv("APIP_DEMO_MODE", "false") - t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") - t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") - t.Setenv("DATABASE_ENCRYPTION_KEY", "") - t.Setenv("AUTH_JWT_SECRET_KEY", "") - t.Setenv("AUTH_JWT_ENABLED", "false") - // Use a blocking file as parent so the key file path resolves but can't be - // created, ensuring LoadConfig reaches the missing-key error path. - blockingFile := filepath.Join(t.TempDir(), "not-a-dir") - require.NoError(t, os.WriteFile(blockingFile, []byte("block"), 0600)) - t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", filepath.Join(blockingFile, "secret-encryption.key")) +// Valid 32-byte keys encoded as 64 hex chars. +const ( + validInlineKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + validJWTKey = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +) - _, err := LoadConfig("") - assert.Error(t, err, "LoadConfig must return an error when no encryption key is configured and DEMO_MODE is off") - assert.Contains(t, err.Error(), "failed to load secret key file") +// setValidKeys provides both required keys so a test starts from a passing baseline. +// Individual tests override one of them to exercise the failure paths. t.Setenv restores +// the previous values automatically at test end. +func setValidKeys(t *testing.T) { + t.Helper() + t.Setenv("ENCRYPTION_KEY", validInlineKey) + t.Setenv("AUTH_JWT_SECRET_KEY", validJWTKey) + t.Setenv("APIP_DEMO_MODE", "") } -// Missing key with APIP_DEMO_MODE unset → demo mode is the default, so an -// ephemeral key is generated and LoadConfig succeeds. -func TestLoadConfig_MissingSecretEncryptionKey_UnsetDemoMode_DefaultsToDemo(t *testing.T) { - os.Unsetenv("APIP_DEMO_MODE") - t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") - t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") - t.Setenv("DATABASE_ENCRYPTION_KEY", "") - t.Setenv("AUTH_JWT_SECRET_KEY", "") +// Both keys provided and valid → LoadConfig succeeds and passes the encryption key through. +func TestLoadConfig_ValidKeys_Succeeds(t *testing.T) { + setValidKeys(t) cfg, err := LoadConfig("") - require.NoError(t, err, "LoadConfig must succeed when APIP_DEMO_MODE is unset (demo is the default)") - assert.NotEmpty(t, cfg.Database.SecretEncryptionKey, - "an ephemeral key must be generated when no key is configured and APIP_DEMO_MODE is unset") -} - -// TC-35: Ephemeral key must be unique each LoadConfig call (i.e. truly random, not a constant). -// Without an explicit key file path (e.g. postgres with no DATABASE_SECRET_ENCRYPTION_KEY_FILE and no DB path), -// no persistence is possible and the key is still ephemeral — each LoadConfig call produces a different value. -func TestLoadConfig_EphemeralKey_IsRandomPerCall_NoDatabasePath(t *testing.T) { - // Use a temp file as the "parent directory" — os.MkdirAll can't create a - // directory where a file already exists, so persistence always fails. - blockingFile := filepath.Join(t.TempDir(), "not-a-dir") - require.NoError(t, os.WriteFile(blockingFile, []byte("block"), 0600)) - t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", filepath.Join(blockingFile, "secret-encryption.key")) - t.Setenv("APIP_DEMO_MODE", "true") - t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") - t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") - t.Setenv("DATABASE_ENCRYPTION_KEY", "") - t.Setenv("AUTH_JWT_SECRET_KEY", "") - - cfg1, err := LoadConfig("") - require.NoError(t, err) - cfg2, err := LoadConfig("") require.NoError(t, err) - - assert.NotEqual(t, cfg1.Database.SecretEncryptionKey, cfg2.Database.SecretEncryptionKey, - "without a database path, ephemeral keys must differ between independent LoadConfig calls") + assert.Equal(t, validInlineKey, cfg.EncryptionKey) } -// With a key file path configured, the first LoadConfig generates and persists a 32-byte -// binary key file; subsequent calls load the same key — secrets survive restarts. -func TestLoadConfig_DemoMode_PersistsAndReloadsKey(t *testing.T) { - dir := t.TempDir() - keyFile := filepath.Join(dir, "secret-encryption.key") +// ENCRYPTION_KEY is required and never generated — missing it fails startup (even in demo mode). +func TestLoadConfig_MissingEncryptionKey_Errors(t *testing.T) { + setValidKeys(t) + t.Setenv("APIP_DEMO_MODE", "true") // demo does not relax the requirement + t.Setenv("ENCRYPTION_KEY", "") - t.Setenv("APIP_DEMO_MODE", "true") - t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") - t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") - t.Setenv("DATABASE_ENCRYPTION_KEY", "") - t.Setenv("AUTH_JWT_SECRET_KEY", "") - t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", keyFile) - - cfg1, err := LoadConfig("") - require.NoError(t, err) - require.NotEmpty(t, cfg1.Database.SecretEncryptionKey) + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "ENCRYPTION_KEY is required") +} - // Key file must be a 32-byte binary file. - data, readErr := os.ReadFile(keyFile) - require.NoError(t, readErr, "key file must exist after first LoadConfig") - assert.Len(t, data, 32, "key file must contain exactly 32 bytes") +// A provided ENCRYPTION_KEY must be an AES-256-sized key (64 hex / base64→32 bytes). +func TestLoadConfig_InvalidEncryptionKey_Errors(t *testing.T) { + setValidKeys(t) + t.Setenv("ENCRYPTION_KEY", "not-a-valid-32-byte-key") - // Second call must load the same key. - cfg2, err := LoadConfig("") - require.NoError(t, err) - assert.Equal(t, cfg1.Database.SecretEncryptionKey, cfg2.Database.SecretEncryptionKey, - "second LoadConfig must reuse the persisted key so secrets remain readable after restart") + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid ENCRYPTION_KEY") } -// TestLoadConfig_ExplicitSecretEncryptionKey verifies the normal path where the -// env var is set — no ephemeral generation occurs and the value is passed through. -func TestLoadConfig_ExplicitSecretEncryptionKey_UsedAsIs(t *testing.T) { - const stableKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", stableKey) - t.Setenv("APIP_DEMO_MODE", "false") - t.Setenv("AUTH_JWT_ENABLED", "false") +// AUTH_JWT_SECRET_KEY is required (JWT auth is enabled by default) and never generated. +func TestLoadConfig_MissingJWTSecretKey_Errors(t *testing.T) { + setValidKeys(t) + t.Setenv("APIP_DEMO_MODE", "true") // demo does not relax the requirement + t.Setenv("AUTH_JWT_SECRET_KEY", "") - cfg, err := LoadConfig("") - require.NoError(t, err) - assert.Equal(t, stableKey, cfg.Database.SecretEncryptionKey) + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "AUTH_JWT_SECRET_KEY is required") } -// Ensure APIP_DEMO_MODE="1" is also accepted as a truthy value. -func TestLoadConfig_DemoModeOne_AcceptedAsTruthy(t *testing.T) { - t.Setenv("APIP_DEMO_MODE", "1") - t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") - t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") +// A provided AUTH_JWT_SECRET_KEY must be an AES-256-sized key (64 hex / base64→32 bytes). +func TestLoadConfig_InvalidJWTSecretKey_Errors(t *testing.T) { + setValidKeys(t) + t.Setenv("AUTH_JWT_SECRET_KEY", "not-a-valid-32-byte-key") - cfg, err := LoadConfig("") - require.NoError(t, err) - assert.NotEmpty(t, cfg.Database.SecretEncryptionKey) + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid AUTH_JWT_SECRET_KEY") } -// Ensure APIP_DEMO_MODE with surrounding whitespace is handled gracefully. -func TestLoadConfig_DemoModeWhitespace_Trimmed(t *testing.T) { - t.Setenv("APIP_DEMO_MODE", " true ") - t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") - t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") +// --- valid32ByteKey unit coverage --- - cfg, err := LoadConfig("") - require.NoError(t, err) - assert.NotEmpty(t, cfg.Database.SecretEncryptionKey) +func TestValid32ByteKey(t *testing.T) { + require.True(t, valid32ByteKey(validInlineKey), "64 hex chars must be valid") + // 32 bytes base64-encoded (standard encoding, 44 chars). + require.True(t, valid32ByteKey("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")) + require.False(t, valid32ByteKey(""), "empty must be invalid") + require.False(t, valid32ByteKey("short"), "short strings must be invalid") + require.False(t, valid32ByteKey("zz"+validInlineKey[2:]), "non-hex 64-char must be invalid") } -// cleanEnvForTest clears all environment variables that LoadConfig reads from the -// environment so each test starts from a known baseline. +// Clear env vars that LoadConfig reads so each test starts from a known baseline and host +// environment values don't leak into assertions. func init() { - // Clear vars that would leak from the host environment and break assertions. for _, v := range []string{ - "PLATFORM_SECRET_ENCRYPTION_KEY", - "APIP_DATABASE_SECRET_ENCRYPTION_KEY", + "ENCRYPTION_KEY", + "AUTH_JWT_SECRET_KEY", "APIP_DEMO_MODE", } { os.Unsetenv(v) diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index aa4d5c54ad..0e5cd0129f 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -1,3 +1,5 @@ +//go:build integration + /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). * @@ -22,11 +24,6 @@ // data-access behavior — pagination, multi-table writes and delete cascades — // so backend-specific bugs (e.g. SQL Server LIMIT/cascade-path issues) are // caught instead of being hidden behind the SQLite unit-test path. -// -// Build-tagged `integration` so it is excluded from the default `go test ./...`. -// -//go:build integration - package integration import ( @@ -44,9 +41,11 @@ import ( ) func TestMain(m *testing.M) { - // Allow GetConfig() to generate an ephemeral secret_encryption_key so tests - // that exercise subscription_repository.go don't panic at startup. + // ENCRYPTION_KEY and AUTH_JWT_SECRET_KEY are required + // provide valid 64-hex keys so GetConfig() succeeds for tests that exercise subscription_repository.go. os.Setenv("APIP_DEMO_MODE", "true") + os.Setenv("ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + os.Setenv("AUTH_JWT_SECRET_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") os.Exit(m.Run()) } diff --git a/platform-api/internal/repository/api_deployments_test.go b/platform-api/internal/repository/api_deployments_test.go index dfd96e6a63..fc6a5a8d89 100644 --- a/platform-api/internal/repository/api_deployments_test.go +++ b/platform-api/internal/repository/api_deployments_test.go @@ -595,8 +595,10 @@ func TestGetControlPlaneDeploymentsByGateway_ExcludesGatewayOrigin(t *testing.T) } func TestMain(m *testing.M) { - // Allow GetConfig() to generate an ephemeral secret_encryption_key without failing. + // ENCRYPTION_KEY and AUTH_JWT_SECRET_KEY are required. os.Setenv("APIP_DEMO_MODE", "true") + os.Setenv("ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + os.Setenv("AUTH_JWT_SECRET_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") log.SetFlags(log.LstdFlags | log.Lshortfile) code := m.Run() diff --git a/platform-api/internal/repository/subscription_repository.go b/platform-api/internal/repository/subscription_repository.go index 1105045341..2746c34983 100644 --- a/platform-api/internal/repository/subscription_repository.go +++ b/platform-api/internal/repository/subscription_repository.go @@ -49,20 +49,13 @@ func hashSubscriptionToken(token string) string { } // getSubscriptionTokenEncryptionKey returns the 32-byte key for subscription token encryption. -// Precedence: DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY → DATABASE_ENCRYPTION_KEY → AUTH_JWT_SECRET_KEY. +// The single configured ENCRYPTION_KEY is used for all at-rest encryption. func getSubscriptionTokenEncryptionKey() ([]byte, error) { cfg := config.GetConfig() - keyStr := cfg.Database.SubscriptionTokenEncryptionKey - if keyStr == "" { - keyStr = cfg.Database.EncryptionKey + if cfg.EncryptionKey == "" { + return nil, fmt.Errorf("subscription token encryption requires ENCRYPTION_KEY") } - if keyStr == "" { - keyStr = cfg.Auth.JWT.SecretKey - } - if keyStr == "" { - return nil, fmt.Errorf("subscription token encryption requires DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY, DATABASE_ENCRYPTION_KEY, or AUTH_JWT_SECRET_KEY") - } - return utils.DeriveEncryptionKey(keyStr) + return utils.DeriveEncryptionKey(cfg.EncryptionKey) } // SubscriptionRepo implements SubscriptionRepository @@ -241,28 +234,19 @@ func (r *SubscriptionRepo) ListByFilters(orgUUID string, apiUUID *string, subscr return list, rows.Err() } -// decryptionKeyCandidates returns all derived keys to try during decryption, in precedence order. -// Tokens may have been encrypted with any of the three key sources across different deployments, -// so decryption must attempt all of them: SubscriptionTokenEncryptionKey → EncryptionKey → SecretKey. +// decryptionKeyCandidates returns the derived key(s) to try during decryption. +// With the single consolidated ENCRYPTION_KEY there is at most one candidate; the slice +// shape is retained so callers can keep iterating (and so back-compat candidates could be +// re-introduced for a migration if ever needed). func decryptionKeyCandidates() [][]byte { cfg := config.GetConfig() - sources := []string{ - cfg.Database.SubscriptionTokenEncryptionKey, - cfg.Database.EncryptionKey, - cfg.Auth.JWT.SecretKey, - } - seen := map[string]bool{} - var keys [][]byte - for _, s := range sources { - if s == "" || seen[s] { - continue - } - seen[s] = true - if k, err := utils.DeriveEncryptionKey(s); err == nil { - keys = append(keys, k) - } + if cfg.EncryptionKey == "" { + return nil + } + if k, err := utils.DeriveEncryptionKey(cfg.EncryptionKey); err == nil { + return [][]byte{k} } - return keys + return nil } // decryptSubscriptionToken decrypts stored token for API response. diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 148aaf5262..c1dab1fb9d 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -21,11 +21,9 @@ import ( "context" "crypto/rand" "crypto/rsa" - "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" - "encoding/hex" "encoding/pem" "fmt" "log/slog" @@ -264,15 +262,9 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, llmProxyService := service.NewLLMProxyService(llmProxyRepo, llmProviderRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) mcpProxyService := service.NewMCPProxyService(mcpProxyRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) - // Initialize the shared database encryption key used for all encrypted DB columns. - // DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY is accepted as a legacy alias. - // DeriveEncryptionKey requires 64-char hex or base64-to-32-bytes; when falling back to - // the raw JWT secret (arbitrary length), hash it to a valid 64-char hex key. - dbEncryptionKey := cfg.Database.EncryptionKey - if dbEncryptionKey == "" && cfg.Auth.JWT.SecretKey != "" { - h := sha256.Sum256([]byte(cfg.Auth.JWT.SecretKey)) - dbEncryptionKey = hex.EncodeToString(h[:]) - } + // The single configured encryption key (ENCRYPTION_KEY) is used for all encrypted DB + // columns (secrets, subscription tokens, WebSub HMAC secrets) + dbEncryptionKey := cfg.EncryptionKey llmProviderDeploymentService := service.NewLLMProviderDeploymentService( llmProviderRepo, llmTemplateRepo, @@ -320,15 +312,10 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, mcpProxyService, ) - // Initialize secret vault and service. - // Key precedence: PLATFORM_SECRET_ENCRYPTION_KEY → DATABASE_ENCRYPTION_KEY → JWT secret hash. - secretKeyStr := cfg.Database.SecretEncryptionKey - if secretKeyStr == "" { - secretKeyStr = dbEncryptionKey - } - secretKey, keyErr := utils.DeriveEncryptionKey(secretKeyStr) + // Initialize secret vault and service using the single configured encryption key. + secretKey, keyErr := utils.DeriveEncryptionKey(cfg.EncryptionKey) if keyErr != nil { - return nil, fmt.Errorf("invalid secret encryption key: %w", keyErr) + return nil, fmt.Errorf("invalid encryption key: %w", keyErr) } secretVault, vaultErr := internalvault.NewInHouseVault(secretKey) if vaultErr != nil { @@ -860,7 +847,6 @@ func (s *Server) Start(port string, certDir string) error { } } - // GetMux returns the raw ServeMux for testing purposes. func (s *Server) GetMux() *http.ServeMux { return s.mux diff --git a/platform-api/plugins/eventgateway/service/websub_api_hmac_secret.go b/platform-api/plugins/eventgateway/service/websub_api_hmac_secret.go index e96f945674..e1f798651d 100644 --- a/platform-api/plugins/eventgateway/service/websub_api_hmac_secret.go +++ b/platform-api/plugins/eventgateway/service/websub_api_hmac_secret.go @@ -52,7 +52,7 @@ type WebSubAPIHmacSecretService struct { // NewWebSubAPIHmacSecretService creates a new WebSubAPIHmacSecretService. // encryptionKeyStr must be a 32-byte key encoded as 64 hex chars or base64 -// (set via DATABASE_ENCRYPTION_KEY). +// (set via ENCRYPTION_KEY). func NewWebSubAPIHmacSecretService( repo repository.WebSubAPIHmacSecretRepository, websubRepo repository.WebSubAPIRepository, diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index cc6946f815..99f591ba38 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -421,13 +421,13 @@ rather than run insecurely: |---|---|---| | **AI Workspace (BFF)** — auth | Basic / file-based auth allowed | Basic auth **rejected** — OIDC required (`VITE_AUTH_MODE=oidc` + the `OIDC_*` values) | | **AI Workspace (BFF)** — TLS | Auto-generates a self-signed cert when none is mounted | Self-signed fallback **disabled** — a cert/key must be mounted (`BFF_TLS_CERT_FILE` / `BFF_TLS_KEY_FILE`) | -| **Platform API** — secrets | Generates an ephemeral encryption key when none is set | A stable key is **required** (`PLATFORM_SECRET_ENCRYPTION_KEY` or `DATABASE_ENCRYPTION_KEY`) | +| **Platform API** — secrets | `ENCRYPTION_KEY` and `AUTH_JWT_SECRET_KEY` are **required** | Same — both keys required | So before flipping `APIP_DEMO_MODE=false`, make sure you have: 1. **OIDC configured on both services** — follow [Testing with an IDP locally](#testing-with-an-idp-locally) (uncomment the OIDC blocks on both compose services and set the `OIDC_*` values). Basic auth is no longer a fallback. 2. **A real TLS certificate mounted** on the BFF (and the Platform API) — follow [Custom TLS certificates](#custom-tls-certificates-optional) above and uncomment the cert volume lines. The self-signed fallback is gone. -3. **A stable secret encryption key** for the Platform API — set `PLATFORM_SECRET_ENCRYPTION_KEY=$(openssl rand -hex 32)` in your `.env` (otherwise encrypted secrets become unreadable after a restart). See [platform-api/README.md](../../platform-api/README.md). +3. **A stable encryption key** for the Platform API — generate a value by running `openssl rand -hex 32` in a shell, then paste the resulting 64-hex string into your `.env` as `ENCRYPTION_KEY=`. Do **not** put `ENCRYPTION_KEY=$(openssl rand -hex 32)` in `.env` — `.env` files store the literal text and do not run command substitution. Otherwise encrypted secrets become unreadable after a restart. See [platform-api/README.md](../../platform-api/README.md). If any of these is missing, the corresponding service exits at startup with a message naming exactly what to provide. diff --git a/portals/ai-workspace/configs/config-platform-api-template.toml b/portals/ai-workspace/configs/config-platform-api-template.toml index 4bf838e60d..c84fa22e33 100644 --- a/portals/ai-workspace/configs/config-platform-api-template.toml +++ b/portals/ai-workspace/configs/config-platform-api-template.toml @@ -15,9 +15,10 @@ # # QUICK START (file-based auth mode — no external IDP needed): # 1. Copy this file to config-platform-api.toml. -# 2. Set [auth.jwt] secret_key to a random string (or use AUTH_JWT_SECRET_KEY env var). -# 3. Set [auth.file_based] enabled = true and configure users. -# 4. Run: docker compose up +# 2. Set encryption_key to a 32-byte key (or use the ENCRYPTION_KEY env var): openssl rand -hex 32. +# 3. Set [auth.jwt] secret_key to a 32-byte key — openssl rand -hex 32 (or use AUTH_JWT_SECRET_KEY env var). +# 4. Set [auth.file_based] enabled = true and configure users. +# 5. Run: docker compose up # # OIDC mode: keep file_based disabled, set [auth.idp] fields, # and set auth_mode = "oidc" in config.toml with oidc_* fields. @@ -33,6 +34,14 @@ port = "9243" # HTTP/HTTPS listen port for Platform API # Set to false only to temporarily bypass during development. enable_scope_validation = true +# --------------------------------------------------------------------------- +# Encryption +# --------------------------------------------------------------------------- +# Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption +# (secrets, subscription tokens, WebSub HMAC secrets) +# Env: ENCRYPTION_KEY. Generate with: openssl rand -hex 32. +# encryption_key = "" + # Controls authentication for POST /api/v0.9/organizations. # When true, the endpoint requires both a valid Bearer JWT and the # ap:organization:manage scope; requests without that scope are rejected. @@ -58,9 +67,6 @@ path = "/app/data/api_platform.db" # max_idle_conns = 10 # maximum idle connections in the pool # conn_max_lifetime = 300 # seconds before a connection is recycled -# Encryption key for subscription tokens stored in the database (32-byte hex string). -# Required when subscription tokens are used; auto-generated if left empty. -# subscription_token_encryption_key = "" # --------------------------------------------------------------------------- # Authentication diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index 6749e23de2..690a323bfa 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -25,6 +25,15 @@ port = "9243" # Validate OAuth2 scopes on incoming requests. enable_scope_validation = true + +# --------------------------------------------------------------------------- +# Encryption +# --------------------------------------------------------------------------- +# Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption +# (secrets, subscription tokens, WebSub HMAC secrets) +# Env: ENCRYPTION_KEY. Generate with: openssl rand -hex 32. +# encryption_key = "" + # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- @@ -45,8 +54,8 @@ path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) # --------------------------------------------------------------------------- # JWT (local HMAC) — issues signed tokens after file-based login. -# secret_key is auto-generated at startup when not set (ephemeral — sessions reset on restart). -# Set AUTH_JWT_SECRET_KEY env var or secret_key here to persist sessions across restarts. +# secret_key is a 32-byte key (64 hex chars or base64; openssl rand -hex 32). +# Set AUTH_JWT_SECRET_KEY env var Generate with: openssl rand -hex 32. [auth.jwt] enabled = true issuer = "platform-api" diff --git a/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js index 0ebc799a54..3f7a9403ed 100644 --- a/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js +++ b/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js @@ -30,8 +30,8 @@ * TC-3 POST /secrets 500 → proxy creation aborted, no proxy created * * Update flow via Provider tab (TC-4 – TC-6): - * The Provider tab's "Save API Key" button only stages the new value locally - * (setLocalProxy) — the page-level "Save" button is what actually persists it + * Typing into the API Key field stages the new value locally (setLocalProxy) as + * you type — the page-level "Save" button is what actually persists it * via PUT /llm-proxies/{id} and triggers secret rotation. * TC-4 Edit API key with a new plaintext value → POST /secrets called, PUT * body holds placeholder, old secret DELETEd afterward, plaintext absent @@ -292,7 +292,7 @@ describe('AI Workspace — LLM proxy secret management (create flow)', () => { }); // --------------------------------------------------------------------------- -// UPDATE flow (Provider tab: stage via "Save API Key", persist via page Save) +// UPDATE flow (Provider tab: typing the key stages it locally, persist via page Save) // --------------------------------------------------------------------------- describe('AI Workspace — LLM proxy secret management (update flow)', () => { @@ -393,9 +393,8 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => { cy.intercept('POST', '**/secrets').as('createSecret'); cy.intercept('PUT', /\/llm-proxies\/[^/?]+(\?|$)/).as('updateProxy'); - // Stage the new key — "Save API Key" only updates local state. + // Typing the new key stages it into local proxy state. cy.get('input[placeholder="Enter API key"]').type(UPDATED_KEY); - cy.contains('button', 'Save API Key').should('not.be.disabled').click(); // Persist — page-level Save actually fires the update + secret rotation. cy.contains('button', /^Save$/).should('not.be.disabled').click(); @@ -465,7 +464,6 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => { `{{ secret "${explicitHandle}" }}`, { parseSpecialCharSequences: false } ); - cy.contains('button', 'Save API Key').should('not.be.disabled').click(); cy.contains('button', /^Save$/).should('not.be.disabled').click(); cy.wait('@updateProxy', { timeout: 20000 }).then((pi) => { @@ -494,7 +492,6 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => { }); cy.get('input[placeholder="Enter API key"]').type('sk-tc6-will-fail'); - cy.contains('button', 'Save API Key').should('not.be.disabled').click(); cy.contains('button', /^Save$/).should('not.be.disabled').click(); cy.wait('@failSecret'); diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index a5029743b2..1146eb717a 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -15,8 +15,8 @@ # Set the environment variables below for secrets — either export them # in your shell, or create a .env file next to this file: # -# AUTH_JWT_SECRET_KEY= -# PLATFORM_SECRET_ENCRYPTION_KEY=$(openssl rand -hex 32) # stable key for persistent secrets +# AUTH_JWT_SECRET_KEY=$(openssl rand -hex 32) # stable key: signs login JWTs +# ENCRYPTION_KEY=$(openssl rand -hex 32) # stable key: encrypts secrets # # Authentication: # Out of the box the stack uses file-based auth (login admin / admin) — no @@ -56,15 +56,14 @@ services: command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: # Secrets — set these in your shell or in a .env file next to this file. + # AUTH_JWT_SECRET_KEY signs login JWTs; ENCRYPTION_KEY encrypts secrets/subscription + # tokens/HMAC at rest. Both are REQUIRED 32-byte keys (64 hex chars / base64) + # export AUTH_JWT_SECRET_KEY=$(openssl rand -hex 32) + # export ENCRYPTION_KEY=$(openssl rand -hex 32) - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} - DATABASE_PASSWORD=${DATABASE_PASSWORD:-} - - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=${DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY:-} - APIP_DEMO_MODE=${APIP_DEMO_MODE:-true} - # Encryption key for secrets stored in the database (64 hex chars = 32 bytes). - # If unset, a random key is generated on startup — encrypted secrets will be - # unreadable after a container restart. Set a stable value for persistent deployments: - # export PLATFORM_SECRET_ENCRYPTION_KEY=$(openssl rand -hex 32) - - PLATFORM_SECRET_ENCRYPTION_KEY=${PLATFORM_SECRET_ENCRYPTION_KEY:-} # ── OIDC IDP — uncomment to validate tokens against any OIDC provider's # JWKS instead of file-based auth. Set the OIDC_* values in .env. Claim # overrides are optional (default: organization / org_name / org_handle). diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index e6942ec978..27acf6cb0e 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -215,7 +215,7 @@ For a production deployment, set `APIP_DEMO_MODE=false` (a single var passed to `platform-api` and `ai-workspace` services). This turns on fail-fast startup checks: basic / file-based auth is rejected (the OIDC setup in sections 1–3 becomes mandatory), the BFF and Platform API no longer auto-generate self-signed TLS certificates (you must mount your own), -and the Platform API requires a stable `PLATFORM_SECRET_ENCRYPTION_KEY`. +and the Platform API requires a stable `ENCRYPTION_KEY` and `AUTH_JWT_SECRET_KEY`. See [Production hardening (`APIP_DEMO_MODE`)](../README.md#production-hardening-apip_demo_mode) in the main README for the full checklist of what each service requires. diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index 38578c5fad..fc450315eb 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -274,7 +274,7 @@ The portal config (or `APIP_DP_PLATFORMAPI_*` env vars) must point to the Platfo ```toml [platform_api] base_url = "https://platform-api:9243" # env: APIP_DP_PLATFORMAPI_BASEURL -jwt_secret = "" # same as AUTH_JWT_SECRET_KEY — env: APIP_DP_PLATFORMAPI_JWTSECRET +jwt_secret = "" # same as the Platform API's AUTH_JWT_SECRET_KEY — env: APIP_DP_PLATFORMAPI_JWTSECRET insecure = false # set true when Platform API uses a self-signed cert ``` diff --git a/portals/developer-portal/distribution/docker-compose.yaml b/portals/developer-portal/distribution/docker-compose.yaml index 6e163d9fa3..bb278034da 100644 --- a/portals/developer-portal/distribution/docker-compose.yaml +++ b/portals/developer-portal/distribution/docker-compose.yaml @@ -27,9 +27,11 @@ # 3. Open https://localhost:3000/default/views/default (accept the self-signed cert warning) # Login with a user defined in configs/config-platform-api.toml (default: admin / admin) # -# Secrets: -# Set AUTH_JWT_SECRET_KEY in your shell or in a .env file to persist login sessions -# across Platform API restarts. When unset a random key is generated at startup. +# Secrets (both are 32-byte keys — 64 hex chars / base64; generate with openssl rand -hex 32): +# AUTH_JWT_SECRET_KEY — signs login JWTs; the devportal verifies with the same value. +# ENCRYPTION_KEY — encrypts secrets/subscription tokens at rest. +# Both are REQUIRED — set them in your shell or a .env file; the Platform API fails to +# start if either is missing. # # Configuration: # Edit configs/config.toml to customise devportal settings. @@ -65,6 +67,7 @@ services: command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 56c83a21a3..6c4bdc9b0b 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -28,8 +28,10 @@ # 4. Open http://localhost:3000 # Login: admin / admin # -# Set AUTH_JWT_SECRET_KEY in your shell or a .env file to keep sessions alive -# across Platform API restarts. When unset a random key is generated at startup. +# AUTH_JWT_SECRET_KEY (signs login sessions; share the same value with the devportal's +# APIP_DP_PLATFORMAPI_JWTSECRET) and ENCRYPTION_KEY (encrypts data at rest) are both REQUIRED — +# set them in your shell or a .env file. Both are 32-byte keys (64 hex chars / base64 — +# openssl rand -hex 32). The Platform API fails to start if either is missing. services: platform-api: @@ -39,6 +41,7 @@ services: command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index 81e7d62f95..da21f42467 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -33,9 +33,11 @@ # 4. Open https://localhost:3000/default/views/default (accept the self-signed cert warning) # Login with a user defined in configs/config-platform-api.toml (default: admin / admin) # -# Secrets: -# Set AUTH_JWT_SECRET_KEY in your shell or in a .env file to persist login sessions -# across Platform API restarts. When unset a random key is generated at startup. +# Secrets (both are 32-byte keys — 64 hex chars / base64; generate with openssl rand -hex 32): +# AUTH_JWT_SECRET_KEY — signs login JWTs; the devportal verifies with the same value. +# ENCRYPTION_KEY — encrypts secrets/subscription tokens at rest. +# Both are REQUIRED — set them in your shell or a .env file; the Platform API fails to +# start if either is missing. # # TLS: # A self-signed certificate is generated automatically on first start and stored @@ -69,6 +71,7 @@ services: command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data @@ -127,8 +130,8 @@ services: APIP_DP_LOGGING_CONSOLEONLY: "true" # Platform API — used for local auth credential validation. - # Set APIP_DP_PLATFORMAPI_JWTSECRET to the same value as AUTH_JWT_SECRET_KEY so - # the devportal can verify Platform API JWTs locally (no per-request login call). + # Set APIP_DP_PLATFORMAPI_JWTSECRET to the same value as the Platform API's AUTH_JWT_SECRET_KEY + # so the devportal can verify Platform API JWTs locally (no per-request login call). APIP_DP_PLATFORMAPI_BASEURL: "https://platform-api:9243" APIP_DP_PLATFORMAPI_JWTSECRET: ${AUTH_JWT_SECRET_KEY:-} APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup diff --git a/portals/developer-portal/docs/administer/manage-organizations.md b/portals/developer-portal/docs/administer/manage-organizations.md index cbf70b7ddd..89e4fd00d7 100644 --- a/portals/developer-portal/docs/administer/manage-organizations.md +++ b/portals/developer-portal/docs/administer/manage-organizations.md @@ -148,7 +148,7 @@ The Platform API generates a random JWT signing key at startup. Sessions are inv ```bash # In .env (read by both services via docker-compose env_file / APIP_DP_* override) -AUTH_JWT_SECRET_KEY= +AUTH_JWT_SECRET_KEY=<64-hex-char-string> # openssl rand -hex 32 ``` For scripts and CLI tools, get a Bearer token directly from the Platform API and pass it on each request — no session cookie required: diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index dfa35b0f10..4fac90917b 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -45,7 +45,8 @@ services: image: ghcr.io/wso2/api-platform/platform-api:0.11.0 command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - AUTH_JWT_SECRET_KEY: "it-test-jwt-secret-key-not-for-production" + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + AUTH_JWT_SECRET_KEY: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 8901de1441..ccd56905fc 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -29,7 +29,8 @@ services: image: ghcr.io/wso2/api-platform/platform-api:0.11.0 command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - AUTH_JWT_SECRET_KEY: "it-test-jwt-secret-key-not-for-production" + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + AUTH_JWT_SECRET_KEY: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md index 8d80a33e3b..c253ef99e8 100644 --- a/tests/integration-e2e/README.md +++ b/tests/integration-e2e/README.md @@ -166,7 +166,7 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. - Webhooks are signed `t=,v1=` over `"."` and the key / token fields are hybrid-encrypted (RSA-OAEP-SHA256 + AES-256-GCM). platform-api re-encrypts the subscription token at rest, so - `DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY` must be 32 bytes (64 hex chars). + `ENCRYPTION_KEY` must be 32 bytes (64 hex chars). - platform-api resolves the event's **org, API and plan by handle**, so the devportal org's `cpRefId`, the published API's `referenceId`, and the synced plan's `refId` are each set to the corresponding platform-api handle. diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 46f596f8e3..d594966803 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -9,15 +9,15 @@ services: - DATABASE_DRIVER=sqlite3 - DATABASE_PATH=/app/data/platform.db - DATABASE_EXECUTE_SCHEMA_DDL=true - # Must be 32 bytes (64 hex chars); the subscription-token encryption path - # (exercised by the @secured subscription-create handler) rejects shorter keys. - - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + # Single encryption key — must be 32 bytes (64 hex chars). Used for all at-rest + # encryption (secrets, subscription tokens, HMAC) + - ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - AUTH_FILE_BASED_ENABLED=true - AUTH_FILE_BASED_ORGANIZATION_ID=default - AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - AUTH_FILE_BASED_ORGANIZATION_REGION=us - - AUTH_JWT_SECRET_KEY=e2e-integration-secret-key-0123456789 + - AUTH_JWT_SECRET_KEY=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro ports: diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 87149e7e0c..e3901099fc 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -52,15 +52,15 @@ services: - DATABASE_USER=sa - DATABASE_PASSWORD=${MSSQL_PASSWORD:-Strong!Passw0rd} - DATABASE_SSL_MODE=disable - # Must be 32 bytes (64 hex chars); the subscription-token encryption path - # (exercised by the @secured subscription-create handler) rejects shorter keys. - - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + # Single encryption key — must be 32 bytes (64 hex chars). Used for all at-rest + # encryption (secrets, subscription tokens, HMAC) + - ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - AUTH_FILE_BASED_ENABLED=true - AUTH_FILE_BASED_ORGANIZATION_ID=default - AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - AUTH_FILE_BASED_ORGANIZATION_REGION=us - - AUTH_JWT_SECRET_KEY=e2e-integration-secret-key-0123456789 + - AUTH_JWT_SECRET_KEY=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro ports: diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index b302fed9e2..96f817d621 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -53,9 +53,7 @@ services: - DATABASE_USER=apip - DATABASE_PASSWORD=apip - DATABASE_SSL_MODE=disable - # Must be 32 bytes (64 hex chars); the subscription-token encryption path - # (exercised by the webhook subscription.created handler) rejects shorter keys. - - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + - ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef # Force file-based auth + a stable org so the scenario can log in and the # org is seeded (env overrides the mounted config file). - AUTH_FILE_BASED_ENABLED=true @@ -63,7 +61,7 @@ services: - AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - AUTH_FILE_BASED_ORGANIZATION_REGION=us - - AUTH_JWT_SECRET_KEY=e2e-integration-secret-key-0123456789 + - AUTH_JWT_SECRET_KEY=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 # The @devportal stack injects the admin user (with dp:* scopes) here so the # issued JWT is authorized on the developer portal. Empty (the default) means # platform-api uses its built-in admin (ap:* scopes only). @@ -233,7 +231,7 @@ services: # Validate platform-api-issued JWTs locally: this MUST equal the stack's # AUTH_JWT_SECRET_KEY so the admin token is accepted by the devportal. - APIP_DP_PLATFORMAPI_BASEURL=https://platform-api:9243 - - APIP_DP_PLATFORMAPI_JWTSECRET=e2e-integration-secret-key-0123456789 + - APIP_DP_PLATFORMAPI_JWTSECRET=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 - APIP_DP_PLATFORMAPI_INSECURE=true # The webhook delivery worker POSTs over raw https with the default agent, # and platform-api serves a self-signed cert here. APIP_DP_PLATFORMAPI_INSECURE