From 678bef975e69f169ea0d7f2394b371293df124af Mon Sep 17 00:00:00 2001 From: thivindu Date: Thu, 9 Jul 2026 09:56:32 +0530 Subject: [PATCH 1/8] Consolidate encryption keys in platform API --- .github/workflows/ai-workspace-pr-check.yml | 2 +- distribution/all-in-one/docker-compose.yaml | 2 +- docs/ai-workspace/configuration.md | 2 +- .../features/secrets-management.md | 6 +- platform-api/README.md | 35 +- platform-api/config/config.go | 221 ++++++------ platform-api/config/config.toml | 15 +- platform-api/config/config_test.go | 322 ++++++++++++------ platform-api/internal/handler/auth_login.go | 2 +- .../internal/integration/harness_test.go | 9 +- .../repository/api_deployments_test.go | 2 +- .../repository/subscription_repository.go | 44 +-- platform-api/internal/server/server.go | 31 +- .../service/websub_api_hmac_secret.go | 2 +- portals/ai-workspace/README.md | 4 +- .../configs/config-platform-api-template.toml | 21 +- .../configs/config-platform-api.toml | 16 +- portals/ai-workspace/docker-compose.yaml | 16 +- portals/ai-workspace/production/README.md | 2 +- portals/developer-portal/README.md | 2 +- .../distribution/docker-compose.yaml | 12 +- .../docker-compose.platform-api.yaml | 6 +- portals/developer-portal/docker-compose.yaml | 12 +- .../docs/administer/manage-organizations.md | 6 +- tests/integration-e2e/README.md | 2 +- .../docker-compose.sqlite.yaml | 7 +- .../docker-compose.sqlserver.yaml | 7 +- tests/integration-e2e/docker-compose.yaml | 11 +- 28 files changed, 474 insertions(+), 345 deletions(-) diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index 3dfc2a68ef..5fb934d843 100644 --- a/.github/workflows/ai-workspace-pr-check.yml +++ b/.github/workflows/ai-workspace-pr-check.yml @@ -17,7 +17,7 @@ jobs: pr-check: runs-on: ubuntu-24.04 env: - AUTH_JWT_SECRET_KEY: ai-workspace-pr-check-secret-key + ENCRYPTION_KEY: ai-workspace-pr-check-secret-key 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..10442f6631 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -73,7 +73,7 @@ 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} + - 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..d755775478 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -91,7 +91,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 and signs login JWTs (or use `ENCRYPTION_KEY_FILE`) | | `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..12a0d88cea 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. Used for all at-rest encryption (secrets, subscription tokens, HMAC) and for signing login JWTs. In demo mode, if unset a key file is auto-generated next to the database and reused on restart; in production it is required. Mutually exclusive with `ENCRYPTION_KEY_FILE`. | 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:** Always set a stable `ENCRYPTION_KEY` (or `ENCRYPTION_KEY_FILE`) 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. diff --git a/platform-api/README.md b/platform-api/README.md index e5922f0d2b..4b37f40cb4 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -6,7 +6,7 @@ Backend service that powers the API Platform portals, gateways, and automation f ### Prerequisites -Before using the Platform API, obtain a bearer token for authentication. In local JWT mode (default) you can generate a token using the configured `AUTH_JWT_SECRET_KEY`. In IDP mode, obtain a token from your identity provider. +Before using the Platform API, obtain a bearer token for authentication. In local JWT mode (default) tokens are signed with the configured `ENCRYPTION_KEY`. In IDP mode, obtain a token from your identity provider. ### Build and Run @@ -242,20 +242,20 @@ 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`. +> ephemeral encryption key — you must set `ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE` — and it +> warns loudly if `AUTH_JWT_SKIP_VALIDATION=true`. --- #### 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 `ENCRYPTION_KEY` (the same key used for at-rest encryption; see [Encryption](#encryption)). 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 | +|---|---|---------------------------------------------------------------------| +| `ENCRYPTION_KEY` | _(empty)_ | 32-byte key; signs HMAC login tokens and verification | +| `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: @@ -267,7 +267,7 @@ go run ./cmd/main.go Production with HMAC verification: ```bash -export AUTH_JWT_SECRET_KEY= +export ENCRYPTION_KEY= export AUTH_JWT_ISSUER=https://your-token-issuer go run ./cmd/main.go ``` @@ -276,7 +276,6 @@ go run ./cmd/main.go | Old name | New name | |---|---| -| `JWT_SECRET_KEY` | `AUTH_JWT_SECRET_KEY` | | `JWT_ISSUER` | `AUTH_JWT_ISSUER` | | `JWT_SKIP_VALIDATION` | `AUTH_JWT_SKIP_VALIDATION` | | `JWT_SKIP_PATHS` | `AUTH_SKIP_PATHS` | @@ -369,7 +368,19 @@ 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 + +A single key protects all at-rest encryption (secrets, subscription tokens, WebSub HMAC secrets) and signs local HMAC login JWTs. Provide **exactly one** of `ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE`. + +| Variable | Default | Description | +|---|---|---| +| `ENCRYPTION_KEY` | _(empty)_ | 32-byte AES-256 key as 64 hex chars or base64 (32 bytes). Generate with `openssl rand -hex 32`. | +| `ENCRYPTION_KEY_FILE` | _(empty)_ | Path to a 32-byte binary key file (read on every start). Mutually exclusive with `ENCRYPTION_KEY`. | + +In **demo mode** (default), if neither is set a key file is auto-generated next to the SQLite database (`/secret-encryption.key`) and reused on restart. In **production** (`APIP_DEMO_MODE=false`), one of the two must be provided or startup fails — a key is never auto-generated. --- diff --git a/platform-api/config/config.go b/platform-api/config/config.go index a089b808ab..8ed94cc25d 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -19,6 +19,7 @@ package config import ( "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -85,6 +86,9 @@ type Server struct { OpenAPISpecPath string `koanf:"openapi_spec_path"` LLMTemplateDefinitionsPath string `koanf:"llm_template_definitions_path"` + EncryptionKey string `koanf:"encryption_key"` + EncryptionKeyFile string `koanf:"encryption_key_file"` + Database Database `koanf:"database"` Auth Auth `koanf:"auth"` WebSocket WebSocket `koanf:"websocket"` @@ -184,7 +188,6 @@ type CORS struct { // JWT holds configuration for local HMAC JWT authentication. type JWT struct { Enabled bool `koanf:"enabled"` - SecretKey string `koanf:"secret_key"` Issuer string `koanf:"issuer"` SkipValidation bool `koanf:"skip_validation"` } @@ -214,16 +217,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,78 +362,106 @@ 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 err := resolveEncryptionKey(cfg); err != nil { + return nil, err + } + + return cfg, nil +} + +// resolveEncryptionKey resolves cfg.EncryptionKey from either the inline ENCRYPTION_KEY or the +// ENCRYPTION_KEY_FILE (a 32-byte binary key file). The two sources are mutually exclusive, and +// the key is re-read on every start/restart so a persisted key keeps encrypted data readable. +// +// Rules: +// - Both provided → error (configure exactly one). +// - Inline ENCRYPTION_KEY → validated (64 hex or base64→32 bytes); never written to a file. +// - ENCRYPTION_KEY_FILE only → read + validated on every start; never auto-generated. +// - Neither, non-demo mode → error (a key is never auto-generated in production). +// - Neither, demo mode → key file path defaults alongside the database file; the key +// is generated on first run and reloaded on restart. If no path can be derived, an ephemeral +// key is used and encrypted data will not survive a restart. +func resolveEncryptionKey(cfg *Server) error { + // Mutual exclusivity: never accept both an inline key and a key file. + if cfg.EncryptionKey != "" && cfg.EncryptionKeyFile != "" { + return fmt.Errorf("configure only one of ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, not both") + } + + switch { + case cfg.EncryptionKey != "": + // Inline key from config.toml / ENCRYPTION_KEY. Validate; never persist to a file. + if !validEncryptionKey(cfg.EncryptionKey) { + return fmt.Errorf("invalid ENCRYPTION_KEY: must be 64 hex characters or base64 " + + "decoding to 32 bytes (generate one with: openssl rand -hex 32)") } - key, err := generateRandomSecret() + return nil + + case cfg.EncryptionKeyFile != "": + // Explicit key file: read and validate on every start/restart. Never auto-generate. + hexKey, err := loadEncryptionKeyFile(cfg.EncryptionKeyFile) if err != nil { - return nil, fmt.Errorf("failed to generate JWT secret key: %w", err) + return fmt.Errorf("failed to load ENCRYPTION_KEY_FILE: %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 - } + cfg.EncryptionKey = hexKey + return nil + + default: + // Neither provided. + if !demoMode() { + return fmt.Errorf("no encryption key configured. Set ENCRYPTION_KEY or " + + "ENCRYPTION_KEY_FILE when APIP_DEMO_MODE=false (generate one with: openssl rand -hex 32)") + } + + // Demo mode: default the key file path alongside the database file so the generated key + // is persisted and reloaded on restart (encrypted data survives restarts). + if cfg.EncryptionKeyFile == "" && cfg.Database.Path != "" { + cfg.EncryptionKeyFile = filepath.Join(filepath.Dir(cfg.Database.Path), "secret-encryption.key") } - } - 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") + if cfg.EncryptionKeyFile == "" { + // No path available to persist (e.g. a non-SQLite driver with no DB path) — fall back + // to an ephemeral key. Encrypted data will not survive a restart. + key, err := generateRandomSecret() + if err != nil { + return fmt.Errorf("failed to generate ephemeral encryption key: %w", err) + } + cfg.EncryptionKey = key + slog.Warn("APIP_DEMO_MODE: using an ephemeral random encryption key (no key file path to " + + "persist) — encrypted secrets, subscription tokens, and login sessions will be unusable " + + "after restart. Set ENCRYPTION_KEY or ENCRYPTION_KEY_FILE to persist across restarts.") + return nil } - // Demo mode with no usable key file — fall back to an ephemeral key. - // Secrets will not survive restarts. - key, err := generateRandomSecret() + hexKey, err := loadOrGenerateEncryptionKeyFile(cfg.EncryptionKeyFile) if err != nil { - return nil, fmt.Errorf("failed to generate secret encryption key: %w", err) + // Could not create/read the key file — fall back to an ephemeral key in demo mode. + slog.Warn("APIP_DEMO_MODE: could not initialise encryption key file, falling back to an "+ + "ephemeral key (encrypted data will not survive a restart)", + slog.String("path", cfg.EncryptionKeyFile), slog.Any("err", err)) + key, genErr := generateRandomSecret() + if genErr != nil { + return fmt.Errorf("failed to generate ephemeral encryption key: %w", genErr) + } + cfg.EncryptionKey = key + return nil } - 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.") + cfg.EncryptionKey = hexKey + return nil } +} - return cfg, nil +// validEncryptionKey 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 validEncryptionKey(keyStr string) bool { + if len(keyStr) == 64 { + if k, err := hex.DecodeString(keyStr); err == nil && len(k) == 32 { + return true + } + } + if k, err := base64.StdEncoding.DecodeString(keyStr); err == nil && len(k) == 32 { + return true + } + return false } func generateRandomSecret() (string, error) { @@ -461,39 +482,39 @@ func demoMode() bool { return v == "true" || v == "1" } -const secretKeySize = 32 // AES-256 +const encryptionKeySize = 32 // AES-256 -// loadOrGenerateSecretKeyFile loads a 32-byte binary key file from filePath, creating it +// loadOrGenerateEncryptionKeyFile 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) +// Concurrent first-time callers are safe: generateEncryptionKeyFile uses O_CREATE|O_EXCL so +// only one writer succeeds; others see os.ErrExist and fall through to loadEncryptionKeyFile. +func loadOrGenerateEncryptionKeyFile(filePath string) (string, error) { + err := generateEncryptionKeyFile(filePath) switch { case err == nil: - slog.Info("APIP_DEMO_MODE: generated and persisted secret encryption key — encrypted secrets will survive restarts", + slog.Info("APIP_DEMO_MODE: generated and persisted encryption key — encrypted data 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")) + slog.String("hint", "Set and provide ENCRYPTION_KEY or a shared ENCRYPTION_KEY_FILE 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) + return loadEncryptionKeyFile(filePath) } -// generateSecretKeyFile creates parent directories and writes 32 cryptographically +// generateEncryptionKeyFile 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 { +func generateEncryptionKeyFile(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) + key := make([]byte, encryptionKeySize) if _, err := rand.Read(key); err != nil { return fmt.Errorf("failed to generate random key: %w", err) } @@ -513,26 +534,26 @@ func generateSecretKeyFile(filePath string) error { 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) { +// loadEncryptionKeyFile reads the key file, validates its size (32 raw bytes), warns if +// world-readable, and returns the key as a 64-char hex string. +func loadEncryptionKeyFile(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) + return "", fmt.Errorf("failed to stat encryption 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.Warn("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) + return "", fmt.Errorf("failed to read encryption 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)) + if len(data) != encryptionKeySize { + return "", fmt.Errorf("encryption key file %s has wrong size: expected %d bytes, got %d", filePath, encryptionKeySize, len(data)) } - slog.Info("APIP_DEMO_MODE: loaded persisted secret encryption key", slog.String("path", filePath)) + slog.Info("Loaded persisted encryption key from file", slog.String("path", filePath)) return hex.EncodeToString(data), nil } @@ -557,6 +578,10 @@ func envToKoanfKey(s string) string { return "llm_template_definitions_path" case "enable_scope_validation": return "enable_scope_validation" + case "encryption_key": + return "encryption_key" + case "encryption_key_file": + return "encryption_key_file" // Database case "database_driver": @@ -581,14 +606,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": @@ -597,8 +614,6 @@ func envToKoanfKey(s string) string { // Auth JWT case "auth_jwt_enabled": return "auth.jwt.enabled" - case "auth_jwt_secret_key": - return "auth.jwt.secret_key" case "auth_jwt_issuer": return "auth.jwt.issuer" case "auth_jwt_skip_validation": diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 10a9dbbdd2..665088e702 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -24,6 +24,18 @@ 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) and for signing local HMAC +# login JWTs. Provide exactly ONE of encryption_key / encryption_key_file +# (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. +# In demo mode, if neither is set a key file is auto-created next to the database +# and reused on restart; in production one of them is required. +# encryption_key = "" +# encryption_key_file = "" + # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- @@ -44,8 +56,7 @@ 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. +# Login JWTs are signed with the top-level encryption_key (ENCRYPTION_KEY). [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..28a8aa0c29 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -19,6 +19,7 @@ package config import ( + "encoding/hex" "os" "path/filepath" "testing" @@ -27,158 +28,257 @@ import ( "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", "") +// A valid inline encryption key: 64 hex chars decoding to 32 bytes. +const validInlineKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - 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") +// clearKeyEnv resets all encryption-related env vars to empty so each test starts clean. +// t.Setenv restores the previous value automatically at test end. +func clearKeyEnv(t *testing.T) { + t.Helper() + t.Setenv("ENCRYPTION_KEY", "") + t.Setenv("ENCRYPTION_KEY_FILE", "") + t.Setenv("DATABASE_DB_PATH", "") + t.Setenv("APIP_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")) +// writeValidKeyFile writes a 32-byte binary key file and returns its path and the +// expected hex-encoded key value. +func writeValidKeyFile(t *testing.T, dir, name string) (path, hexKey string) { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i + 1) + } + path = filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, key, 0600)) + return path, hex.EncodeToString(key) +} - _, 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") +// setDemoDBPath points DATABASE_DB_PATH at a fresh temp file and returns the default +// key-file path (alongside the DB) that demo-mode resolution would use. +func setDemoDBPath(t *testing.T) (defaultKeyFile string) { + t.Helper() + dir := t.TempDir() + t.Setenv("DATABASE_DB_PATH", filepath.Join(dir, "api_platform.db")) + return filepath.Join(dir, "secret-encryption.key") } -// 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", "") +// --- Demo mode --- - 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")) +// 1.i — Demo, neither provided, DB path present → a key is generated, persisted to the default +// key file, and reloaded (identical) on the next start. +func TestResolveKey_Demo_NeitherProvided_GeneratesAndPersists(t *testing.T) { + clearKeyEnv(t) 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", "") + keyFile := setDemoDBPath(t) cfg1, err := LoadConfig("") require.NoError(t, err) + require.NotEmpty(t, cfg1.EncryptionKey) + assert.Equal(t, keyFile, cfg1.EncryptionKeyFile, "key file path must default alongside the DB") + + data, readErr := os.ReadFile(keyFile) + require.NoError(t, readErr, "key file must be created on first start") + assert.Len(t, data, 32, "key file must contain exactly 32 bytes") + 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, cfg1.EncryptionKey, cfg2.EncryptionKey, + "the persisted key must be reloaded identically on restart") } -// 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") +// 1.i (edge) — Demo, neither provided, no DB path → falls back to an ephemeral key that differs +// per call (nothing to persist). Exercised directly since empty env values can't clear the +// default Database.Path (koanf skips empty env values). +func TestResolveKey_Demo_NeitherProvided_NoDBPath_Ephemeral(t *testing.T) { + t.Setenv("APIP_DEMO_MODE", "true") + + cfg1 := &Server{} // no EncryptionKey, no EncryptionKeyFile, empty Database.Path + require.NoError(t, resolveEncryptionKey(cfg1)) + cfg2 := &Server{} + require.NoError(t, resolveEncryptionKey(cfg2)) + + require.NotEmpty(t, cfg1.EncryptionKey) + assert.Empty(t, cfg1.EncryptionKeyFile, "no key file path can be derived without a DB path") + assert.NotEqual(t, cfg1.EncryptionKey, cfg2.EncryptionKey, + "without a persistable path, demo keys must be ephemeral and differ per call") +} +// 1.ii — Demo, only ENCRYPTION_KEY (valid) → used as-is; never written to the key file. +func TestResolveKey_Demo_InlineKeyValid_NotPersisted(t *testing.T) { + clearKeyEnv(t) 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) + keyFile := setDemoDBPath(t) + t.Setenv("ENCRYPTION_KEY", validInlineKey) - cfg1, err := LoadConfig("") + cfg, err := LoadConfig("") require.NoError(t, err) - require.NotEmpty(t, cfg1.Database.SecretEncryptionKey) + assert.Equal(t, validInlineKey, cfg.EncryptionKey) - // 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") + _, statErr := os.Stat(keyFile) + assert.True(t, os.IsNotExist(statErr), "an inline key must never be written to the key file") +} - // Second call must load the same key. - cfg2, err := LoadConfig("") +// 1.ii — Demo, only ENCRYPTION_KEY (invalid) → error, no fallback. +func TestResolveKey_Demo_InlineKeyInvalid_Errors(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "true") + t.Setenv("ENCRYPTION_KEY", "not-a-valid-32-byte-key") + + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid ENCRYPTION_KEY") +} + +// 1.iii — Demo, only ENCRYPTION_KEY_FILE (valid) → read from file and used. +func TestResolveKey_Demo_KeyFileValid_Used(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "true") + path, expected := writeValidKeyFile(t, t.TempDir(), "my.key") + t.Setenv("ENCRYPTION_KEY_FILE", path) + + cfg, 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") + assert.Equal(t, expected, cfg.EncryptionKey) } -// 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) +// 1.iii — Demo, only ENCRYPTION_KEY_FILE (wrong size) → error, never auto-generated. +func TestResolveKey_Demo_KeyFileInvalidSize_Errors(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "true") + dir := t.TempDir() + path := filepath.Join(dir, "bad.key") + require.NoError(t, os.WriteFile(path, []byte("too-short"), 0600)) + t.Setenv("ENCRYPTION_KEY_FILE", path) + + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load ENCRYPTION_KEY_FILE") +} + +// 1.iii — Demo, only ENCRYPTION_KEY_FILE (missing) → error, never auto-generated at that path. +func TestResolveKey_Demo_KeyFileMissing_Errors(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "true") + t.Setenv("ENCRYPTION_KEY_FILE", filepath.Join(t.TempDir(), "does-not-exist.key")) + + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load ENCRYPTION_KEY_FILE") +} + +// 1.iv / 2.iv — Both provided → error in demo mode. +func TestResolveKey_Demo_BothProvided_Errors(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "true") + path, _ := writeValidKeyFile(t, t.TempDir(), "my.key") + t.Setenv("ENCRYPTION_KEY", validInlineKey) + t.Setenv("ENCRYPTION_KEY_FILE", path) + + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "only one of ENCRYPTION_KEY or ENCRYPTION_KEY_FILE") +} + +// --- Non-demo (production) mode --- + +// 2.i — Non-demo, neither provided → fatal error; never auto-generated. +func TestResolveKey_NonDemo_NeitherProvided_Errors(t *testing.T) { + clearKeyEnv(t) t.Setenv("APIP_DEMO_MODE", "false") - t.Setenv("AUTH_JWT_ENABLED", "false") + setDemoDBPath(t) // even with a DB path, non-demo must not generate. + + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "no encryption key configured") +} + +// 2.ii — Non-demo, only ENCRYPTION_KEY (valid) → used. +func TestResolveKey_NonDemo_InlineKeyValid_Used(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "false") + t.Setenv("ENCRYPTION_KEY", validInlineKey) cfg, err := LoadConfig("") require.NoError(t, err) - assert.Equal(t, stableKey, cfg.Database.SecretEncryptionKey) + assert.Equal(t, validInlineKey, cfg.EncryptionKey) } -// 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", "") +// 2.ii — Non-demo, only ENCRYPTION_KEY (invalid) → error. +func TestResolveKey_NonDemo_InlineKeyInvalid_Errors(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "false") + t.Setenv("ENCRYPTION_KEY", "short") + + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid ENCRYPTION_KEY") +} + +// 2.iii — Non-demo, only ENCRYPTION_KEY_FILE (valid) → read and used. +func TestResolveKey_NonDemo_KeyFileValid_Used(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "false") + path, expected := writeValidKeyFile(t, t.TempDir(), "prod.key") + t.Setenv("ENCRYPTION_KEY_FILE", path) cfg, err := LoadConfig("") require.NoError(t, err) - assert.NotEmpty(t, cfg.Database.SecretEncryptionKey) + assert.Equal(t, expected, cfg.EncryptionKey) +} + +// 2.iv — Both provided → error in non-demo mode. +func TestResolveKey_NonDemo_BothProvided_Errors(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "false") + path, _ := writeValidKeyFile(t, t.TempDir(), "prod.key") + t.Setenv("ENCRYPTION_KEY", validInlineKey) + t.Setenv("ENCRYPTION_KEY_FILE", path) + + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "only one of ENCRYPTION_KEY or ENCRYPTION_KEY_FILE") } -// 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", "") +// --- APIP_DEMO_MODE parsing --- + +// APIP_DEMO_MODE unset → defaults to demo, so neither-provided generates a key. +func TestResolveKey_DemoModeUnset_DefaultsToDemo(t *testing.T) { + clearKeyEnv(t) + os.Unsetenv("APIP_DEMO_MODE") + setDemoDBPath(t) cfg, err := LoadConfig("") require.NoError(t, err) - assert.NotEmpty(t, cfg.Database.SecretEncryptionKey) -} - -// cleanEnvForTest clears all environment variables that LoadConfig reads from the -// environment so each test starts from a known baseline. -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", - "APIP_DEMO_MODE", - } { - os.Unsetenv(v) + assert.NotEmpty(t, cfg.EncryptionKey) +} + +// APIP_DEMO_MODE="1" and whitespace-padded values are treated as truthy (demo). +func TestResolveKey_DemoModeTruthyVariants(t *testing.T) { + for _, v := range []string{"1", " true "} { + t.Run(v, func(t *testing.T) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", v) + setDemoDBPath(t) + + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.NotEmpty(t, cfg.EncryptionKey) + }) } } +// --- validEncryptionKey unit coverage --- + +func TestValidEncryptionKey(t *testing.T) { + require.True(t, validEncryptionKey(validInlineKey), "64 hex chars must be valid") + // 32 bytes base64-encoded (standard encoding, 44 chars). + require.True(t, validEncryptionKey("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")) + require.False(t, validEncryptionKey(""), "empty must be invalid") + require.False(t, validEncryptionKey("short"), "short strings must be invalid") + require.False(t, validEncryptionKey("zz"+validInlineKey[2:]), "non-hex 64-char must be invalid") +} + // validateAuthModeExclusivity: IDP (JWKS) auth must not be enabled alongside the // local JWT or file-based modes — the server must fail fast so operators turn the // local modes off consciously and all tokens are validated against the IDP JWKS. diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index aab14a85ee..8abe6fc25e 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -100,7 +100,7 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - signed, err := token.SignedString([]byte(h.cfg.Auth.JWT.SecretKey)) + signed, err := token.SignedString([]byte(h.cfg.EncryptionKey)) if err != nil { return apperror.Internal.Wrap(err).WithLogMessage("failed to issue token") } diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index aa4d5c54ad..440600d226 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -1,3 +1,7 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). * @@ -23,7 +27,6 @@ // 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 @@ -44,8 +47,8 @@ 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. + // Allow GetConfig() to auto-provision an encryption key so tests that exercise + // subscription_repository.go don't fail at startup. os.Setenv("APIP_DEMO_MODE", "true") 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..ae18d9bf58 100644 --- a/platform-api/internal/repository/api_deployments_test.go +++ b/platform-api/internal/repository/api_deployments_test.go @@ -595,7 +595,7 @@ func TestGetControlPlaneDeploymentsByGateway_ExcludesGatewayOrigin(t *testing.T) } func TestMain(m *testing.M) { - // Allow GetConfig() to generate an ephemeral secret_encryption_key without failing. + // Allow GetConfig() to auto-provision an encryption key without failing. os.Setenv("APIP_DEMO_MODE", "true") log.SetFlags(log.LstdFlags | log.Lshortfile) 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..37ed3cc538 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,10 @@ 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) and for signing local + // HMAC login JWTs. It is validated below via DeriveEncryptionKey. + dbEncryptionKey := cfg.EncryptionKey llmProviderDeploymentService := service.NewLLMProviderDeploymentService( llmProviderRepo, llmTemplateRepo, @@ -320,15 +313,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 { @@ -515,7 +503,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, slogger.Warn("file-based authentication is enabled — this is not recommended for production; please configure an IDP of your choice") } chain = append(chain, middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ - SecretKey: cfg.Auth.JWT.SecretKey, + SecretKey: cfg.EncryptionKey, TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, SkipValidation: false, @@ -599,7 +587,7 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m } return middleware.NewJWTAuthenticator( middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ - SecretKey: cfg.Auth.JWT.SecretKey, + SecretKey: cfg.EncryptionKey, TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, SkipValidation: cfg.Auth.JWT.SkipValidation, @@ -860,7 +848,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..aa8f0c0dd3 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 | Generates an ephemeral encryption key (or persists one next to the DB) when none is set | A stable key is **required** (`ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE`) | 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 — set `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). 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..cd90cab613 100644 --- a/portals/ai-workspace/configs/config-platform-api-template.toml +++ b/portals/ai-workspace/configs/config-platform-api-template.toml @@ -15,7 +15,7 @@ # # 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). +# 2. Set encryption_key to a 32-byte key (or use the ENCRYPTION_KEY env var): openssl rand -hex 32. # 3. Set [auth.file_based] enabled = true and configure users. # 4. Run: docker compose up # @@ -33,6 +33,18 @@ 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) and for signing local HMAC +# login JWTs. Provide exactly ONE of encryption_key / encryption_key_file +# (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. +# In demo mode, if neither is set a key file is auto-created next to the database +# and reused on restart; in production one of them is required. +# encryption_key = "" +# encryption_key_file = "" + # 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 +70,8 @@ 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 = "" +# (Encryption is configured once at the top of this file via encryption_key / +# encryption_key_file — it covers subscription tokens, secrets, and HMAC secrets.) # --------------------------------------------------------------------------- # Authentication @@ -71,7 +82,7 @@ path = "/app/data/api_platform.db" [auth.jwt] enabled = true issuer = "platform-api" -# secret_key = "change-me-to-a-random-secret" # or use AUTH_JWT_SECRET_KEY env var +# Login JWTs are signed with the top-level encryption_key (ENCRYPTION_KEY). # skip_validation = false # NEVER set true in production # IDP (JWKS-based) — enable instead of JWT when using Asgardeo, Keycloak, Auth0, etc. diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index 6749e23de2..4757a4b35a 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -25,6 +25,19 @@ 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) and for signing local HMAC +# login JWTs. Provide exactly ONE of encryption_key / encryption_key_file +# (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. +# In demo mode, if neither is set a key file is auto-created next to the database +# and reused on restart; in production one of them is required. +# encryption_key = "" +# encryption_key_file = "" + # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- @@ -45,8 +58,7 @@ 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. +# Login JWTs are signed with the top-level encryption_key (ENCRYPTION_KEY). [auth.jwt] enabled = true issuer = "platform-api" diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index a5029743b2..be5777108f 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -15,8 +15,7 @@ # 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 +# ENCRYPTION_KEY=$(openssl rand -hex 32) # stable key: encrypts secrets & signs login JWTs # # Authentication: # Out of the box the stack uses file-based auth (login admin / admin) — no @@ -56,15 +55,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=${AUTH_JWT_SECRET_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:-} + # Single encryption key (64 hex chars = 32 bytes) for all at-rest encryption + # (secrets, subscription tokens, HMAC) and for signing local login JWTs. + # In demo mode, if unset a key file is auto-created next to the database and reused + # on restart. Set a stable value for multi-replica/production deployments: + # export ENCRYPTION_KEY=$(openssl rand -hex 32) + - ENCRYPTION_KEY=${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..d1cd035512 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` (or `ENCRYPTION_KEY_FILE`). 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..99bd7549c4 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 ENCRYPTION_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..003cbf29d5 100644 --- a/portals/developer-portal/distribution/docker-compose.yaml +++ b/portals/developer-portal/distribution/docker-compose.yaml @@ -28,8 +28,8 @@ # 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. +# Set ENCRYPTION_KEY in your shell or in a .env file to persist login sessions and +# encrypted data across Platform API restarts. When unset a demo key is used. # # Configuration: # Edit configs/config.toml to customise devportal settings. @@ -64,7 +64,7 @@ services: restart: unless-stopped 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 @@ -149,10 +149,10 @@ 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 ENCRYPTION_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_JWTSECRET: ${ENCRYPTION_KEY:-} APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup # AES-256-GCM key for encrypting subscription tokens and webhook secrets at rest. diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 56c83a21a3..404534affe 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -28,8 +28,8 @@ # 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. +# Set ENCRYPTION_KEY in your shell or a .env file to keep sessions and encrypted data +# alive across Platform API restarts. When unset a demo key is used. services: platform-api: @@ -38,7 +38,7 @@ services: restart: unless-stopped 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..10c4d8f419 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -34,8 +34,8 @@ # 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. +# Set ENCRYPTION_KEY in your shell or in a .env file to persist login sessions and +# encrypted data across Platform API restarts. When unset a demo key is used. # # TLS: # A self-signed certificate is generated automatically on first start and stored @@ -68,7 +68,7 @@ services: restart: unless-stopped 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,10 +127,10 @@ 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 ENCRYPTION_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_JWTSECRET: ${ENCRYPTION_KEY:-} APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup # AES-256-GCM key for encrypting subscription tokens and webhook secrets at rest. diff --git a/portals/developer-portal/docs/administer/manage-organizations.md b/portals/developer-portal/docs/administer/manage-organizations.md index cbf70b7ddd..f090f59ead 100644 --- a/portals/developer-portal/docs/administer/manage-organizations.md +++ b/portals/developer-portal/docs/administer/manage-organizations.md @@ -144,11 +144,11 @@ See `configs/config-platform-api.toml.example` for the complete scope list used ### Session persistence and scripted access -The Platform API generates a random JWT signing key at startup. Sessions are invalidated when it restarts unless you pin the key. Set the **same value** in both services so the devportal can verify JWTs locally without a network round-trip: +The Platform API signs login JWTs with its `ENCRYPTION_KEY`. In demo mode this is auto-generated (and persisted next to the database) if unset; pin it so sessions survive restarts and set the **same value** in both services so the devportal can verify JWTs locally without a network round-trip: ```bash # In .env (read by both services via docker-compose env_file / APIP_DP_* override) -AUTH_JWT_SECRET_KEY= +ENCRYPTION_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: @@ -160,7 +160,7 @@ TOKEN=$(curl -sk -X POST "https://localhost:9243/api/portal/v0.9/auth/login" \ curl -sk -H "Authorization: Bearer $TOKEN" https://localhost:3000/api/v0.9/organizations ``` -The token is verified locally by the Developer Portal using the shared `AUTH_JWT_SECRET_KEY` with no extra call to the Platform API per request. +The token is verified locally by the Developer Portal using the shared `ENCRYPTION_KEY` (the Platform API's signing key) with no extra call to the Platform API per request. > **Note:** Local auth is for development only. For production, configure the global OIDC identity provider via `APIP_DP_IDP_*` environment variables. 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..4e901e2bf8 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -9,15 +9,14 @@ 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) and for signing local login JWTs. + - 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 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..e3f4da36e4 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -52,15 +52,14 @@ 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) and for signing local login JWTs. + - 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 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..9ff88eae5e 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -53,9 +53,9 @@ 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 + # Single encryption key — must be 32 bytes (64 hex chars). Used for all at-rest + # encryption (secrets, subscription tokens, HMAC) and for signing local login JWTs. + - 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 +63,6 @@ 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 # 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). @@ -231,9 +230,9 @@ services: - APIP_DP_ORGANIZATION_AUTOCREATESUBSCRIPTIONPLANS=false - APIP_DP_LOGGING_CONSOLEONLY=true # 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. + # ENCRYPTION_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=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - 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 From effd37c6caf0e70ad408cffbe8b3dbeaec54c3c9 Mon Sep 17 00:00:00 2001 From: thivindu Date: Thu, 9 Jul 2026 10:34:28 +0530 Subject: [PATCH 2/8] Address CodeRabbit comments --- .github/workflows/ai-workspace-pr-check.yml | 2 +- distribution/all-in-one/docker-compose.yaml | 2 +- portals/ai-workspace/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index 5fb934d843..bac36b3054 100644 --- a/.github/workflows/ai-workspace-pr-check.yml +++ b/.github/workflows/ai-workspace-pr-check.yml @@ -17,7 +17,7 @@ jobs: pr-check: runs-on: ubuntu-24.04 env: - ENCRYPTION_KEY: ai-workspace-pr-check-secret-key + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" 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 10442f6631..3507317bc8 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -73,7 +73,7 @@ services: - DATABASE_MAX_IDLE_CONNS=10 - DATABASE_CONN_MAX_LIFETIME=300 - DATABASE_EXECUTE_SCHEMA_DDL=true - - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY must be set to a stable 32-byte key (openssl rand -hex 32)} depends_on: postgres: condition: service_healthy diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index aa8f0c0dd3..97cde52665 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -427,7 +427,7 @@ 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 encryption key** for the Platform API — set `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. From bc567ddb0b4f6d1c4f954324342593b48a91ee6f Mon Sep 17 00:00:00 2001 From: thivindu Date: Thu, 9 Jul 2026 10:46:56 +0530 Subject: [PATCH 3/8] Fix tests --- platform-api/internal/integration/harness_test.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index 440600d226..d9cd4828e5 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -1,7 +1,5 @@ //go:build integration -/* - * Copyright (c) 2026, WSO2 LLC. /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). * @@ -26,10 +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. -// -// -//go:build integration - package integration import ( From 16cf736e861372631f0a01075ef4f9ea1c8e6351 Mon Sep 17 00:00:00 2001 From: thivindu Date: Fri, 10 Jul 2026 20:25:50 +0530 Subject: [PATCH 4/8] Use separate keys for internal and external secret encryption --- .github/workflows/ai-workspace-pr-check.yml | 1 + distribution/all-in-one/docker-compose.yaml | 3 ++ docs/ai-workspace/configuration.md | 3 +- .../features/secrets-management.md | 2 +- platform-api/README.md | 11 ++++--- platform-api/config/config.go | 32 +++++++++++++++++-- platform-api/config/config.toml | 8 +++-- platform-api/config/config_test.go | 29 +++++++++++++---- platform-api/internal/handler/auth_login.go | 2 +- platform-api/internal/server/server.go | 7 ++-- .../configs/config-platform-api-template.toml | 11 ++++--- .../configs/config-platform-api.toml | 8 +++-- portals/ai-workspace/docker-compose.yaml | 6 ++-- portals/developer-portal/README.md | 2 +- .../distribution/docker-compose.yaml | 14 +++++--- .../docker-compose.platform-api.yaml | 7 ++-- portals/developer-portal/docker-compose.yaml | 13 +++++--- .../docs/administer/manage-organizations.md | 6 ++-- .../docker-compose.sqlite.yaml | 3 +- .../docker-compose.sqlserver.yaml | 3 +- tests/integration-e2e/docker-compose.yaml | 7 ++-- 21 files changed, 122 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index bac36b3054..4436abe956 100644 --- a/.github/workflows/ai-workspace-pr-check.yml +++ b/.github/workflows/ai-workspace-pr-check.yml @@ -18,6 +18,7 @@ jobs: runs-on: ubuntu-24.04 env: 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 3507317bc8..d8e9db8abf 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:?AUTH_JWT_SECRET_KEY must be set to a stable 32-byte key (openssl rand -hex 32)} ports: - "3001:3001" volumes: @@ -74,6 +76,7 @@ services: - DATABASE_CONN_MAX_LIFETIME=300 - DATABASE_EXECUTE_SCHEMA_DDL=true - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY must be set to a stable 32-byte key (openssl rand -hex 32)} + - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY must be set to a stable 32-byte key (openssl rand -hex 32)} depends_on: postgres: condition: service_healthy diff --git a/docs/ai-workspace/configuration.md b/docs/ai-workspace/configuration.md index d755775478..9b65f0ca48 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -91,7 +91,8 @@ Sensitive values (JWT signing key, database password) must be passed as environm | Platform API env variable | Description | |--------------------------|-------------| -| `ENCRYPTION_KEY` | 32-byte key (64 hex / base64) — encrypts secrets & subscription tokens and signs login JWTs (or use `ENCRYPTION_KEY_FILE`) | +| `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 (or use `ENCRYPTION_KEY_FILE`) | | `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 12a0d88cea..c24b275b91 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 | |---------|-------------| -| `ENCRYPTION_KEY` | 32-byte AES-256 key as 64 hex characters or base64. Used for all at-rest encryption (secrets, subscription tokens, HMAC) and for signing login JWTs. In demo mode, if unset a key file is auto-generated next to the database and reused on restart; in production it is required. Mutually exclusive with `ENCRYPTION_KEY_FILE`. | +| `ENCRYPTION_KEY` | 32-byte AES-256 key as 64 hex characters or base64. In demo mode, if unset a key file is auto-generated next to the database and reused on restart; in production it is required. Mutually exclusive with `ENCRYPTION_KEY_FILE`. | Generate a stable key with: diff --git a/platform-api/README.md b/platform-api/README.md index 4b37f40cb4..acb21faee4 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -6,7 +6,7 @@ Backend service that powers the API Platform portals, gateways, and automation f ### Prerequisites -Before using the Platform API, obtain a bearer token for authentication. In local JWT mode (default) tokens are signed with the configured `ENCRYPTION_KEY`. In IDP mode, obtain a token from your identity provider. +Before using the Platform API, obtain a bearer token for authentication. In local JWT mode (default) you can generate a token using the configured `AUTH_JWT_SECRET_KEY`. In IDP mode, obtain a token from your identity provider. ### Build and Run @@ -249,11 +249,11 @@ AUTH_IDP_ENABLED=true → IDP mode (JWKS-based verification) #### Local JWT Mode (default) -The server signs and validates HMAC login tokens using `ENCRYPTION_KEY` (the same key used for at-rest encryption; see [Encryption](#encryption)). 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 | |---|---|---------------------------------------------------------------------| -| `ENCRYPTION_KEY` | _(empty)_ | 32-byte key; signs HMAC login tokens and verification | +| `AUTH_JWT_SECRET_KEY` | _(empty)_ | HMAC key for signing/verifying login JWTs — 32-byte value (64 hex or base64; `openssl rand -hex 32`). Required in production; demo generates an ephemeral one. | | `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` | @@ -267,7 +267,7 @@ go run ./cmd/main.go Production with HMAC verification: ```bash -export ENCRYPTION_KEY= +export AUTH_JWT_SECRET_KEY= export AUTH_JWT_ISSUER=https://your-token-issuer go run ./cmd/main.go ``` @@ -276,6 +276,7 @@ go run ./cmd/main.go | Old name | New name | |---|---| +| `JWT_SECRET_KEY` | `AUTH_JWT_SECRET_KEY` | | `JWT_ISSUER` | `AUTH_JWT_ISSUER` | | `JWT_SKIP_VALIDATION` | `AUTH_JWT_SKIP_VALIDATION` | | `JWT_SKIP_PATHS` | `AUTH_SKIP_PATHS` | @@ -373,7 +374,7 @@ In **IDP mode with `AUTH_IDP_VALIDATION_MODE=role`**, IDP roles are resolved fro ### Encryption -A single key protects all at-rest encryption (secrets, subscription tokens, WebSub HMAC secrets) and signs local HMAC login JWTs. Provide **exactly one** of `ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE`. +A single key protects all at-rest encryption (secrets, subscription tokens, WebSub HMAC secrets). Provide **exactly one** of `ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE`. | Variable | Default | Description | |---|---|---| diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 8ed94cc25d..246ad07394 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -188,6 +188,7 @@ type CORS struct { // JWT holds configuration for local HMAC JWT authentication. type JWT struct { Enabled bool `koanf:"enabled"` + SecretKey string `koanf:"secret_key"` Issuer string `koanf:"issuer"` SkipValidation bool `koanf:"skip_validation"` } @@ -362,6 +363,29 @@ func LoadConfig(configPath string) (*Server, error) { return nil, err } + if cfg.Auth.JWT.Enabled { + switch { + case 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 one with: openssl rand -hex 32", + ) + } + // Demo: generate an ephemeral in-memory key (a valid 64-hex/32-byte value) + 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 in-memory demo key; " + + "login sessions will be invalidated on restart. Set AUTH_JWT_SECRET_KEY to persist sessions.") + case !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 err := resolveEncryptionKey(cfg); err != nil { return nil, err } @@ -390,7 +414,7 @@ func resolveEncryptionKey(cfg *Server) error { switch { case cfg.EncryptionKey != "": // Inline key from config.toml / ENCRYPTION_KEY. Validate; never persist to a file. - if !validEncryptionKey(cfg.EncryptionKey) { + if !valid32ByteKey(cfg.EncryptionKey) { return fmt.Errorf("invalid ENCRYPTION_KEY: must be 64 hex characters or base64 " + "decoding to 32 bytes (generate one with: openssl rand -hex 32)") } @@ -450,9 +474,9 @@ func resolveEncryptionKey(cfg *Server) error { } } -// validEncryptionKey reports whether keyStr is a 32-byte key encoded as 64 hex characters +// 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 validEncryptionKey(keyStr string) bool { +func valid32ByteKey(keyStr string) bool { if len(keyStr) == 64 { if k, err := hex.DecodeString(keyStr); err == nil && len(k) == 32 { return true @@ -614,6 +638,8 @@ func envToKoanfKey(s string) string { // Auth JWT case "auth_jwt_enabled": return "auth.jwt.enabled" + case "auth_jwt_secret_key": + return "auth.jwt.secret_key" case "auth_jwt_issuer": return "auth.jwt.issuer" case "auth_jwt_skip_validation": diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 665088e702..c626259be5 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -28,8 +28,8 @@ port = "9243" # Encryption # --------------------------------------------------------------------------- # Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption -# (secrets, subscription tokens, WebSub HMAC secrets) and for signing local HMAC -# login JWTs. Provide exactly ONE of encryption_key / encryption_key_file +# (secrets, subscription tokens, WebSub HMAC secrets) +# Provide exactly ONE of encryption_key / encryption_key_file # (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. # In demo mode, if neither is set a key file is auto-created next to the database # and reused on restart; in production one of them is required. @@ -56,7 +56,9 @@ driver = "sqlite3" # "sqlite3" or "postgres" # --------------------------------------------------------------------------- # JWT (local HMAC) — issues signed tokens after file-based login. -# Login JWTs are signed with the top-level encryption_key (ENCRYPTION_KEY). +# secret_key is a 32-byte key (64 hex chars or base64; openssl rand -hex 32) +# Auto-generated in memory when unset (ephemeral — sessions reset on restart). +# Set AUTH_JWT_SECRET_KEY env var or secret_key here to persist sessions across restarts. [auth.jwt] enabled = true issuer = "platform-api" diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 28a8aa0c29..553f24d6ad 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -32,6 +32,8 @@ import ( const validInlineKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" // clearKeyEnv resets all encryption-related env vars to empty so each test starts clean. +// A JWT signing key is provided so the (separate) AUTH_JWT_SECRET_KEY requirement never +// interferes with these encryption-key assertions — JWT is validated independently. // t.Setenv restores the previous value automatically at test end. func clearKeyEnv(t *testing.T) { t.Helper() @@ -39,6 +41,7 @@ func clearKeyEnv(t *testing.T) { t.Setenv("ENCRYPTION_KEY_FILE", "") t.Setenv("DATABASE_DB_PATH", "") t.Setenv("APIP_DEMO_MODE", "") + t.Setenv("AUTH_JWT_SECRET_KEY", validInlineKey) } // writeValidKeyFile writes a 32-byte binary key file and returns its path and the @@ -268,15 +271,27 @@ func TestResolveKey_DemoModeTruthyVariants(t *testing.T) { } } -// --- validEncryptionKey unit coverage --- +// 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) { + clearKeyEnv(t) + t.Setenv("APIP_DEMO_MODE", "true") + t.Setenv("ENCRYPTION_KEY", validInlineKey) // valid, so the failure is attributable to the JWT key + t.Setenv("AUTH_JWT_SECRET_KEY", "not-a-valid-32-byte-key") + + _, err := LoadConfig("") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid AUTH_JWT_SECRET_KEY") +} + +// --- valid32ByteKey unit coverage --- -func TestValidEncryptionKey(t *testing.T) { - require.True(t, validEncryptionKey(validInlineKey), "64 hex chars must be valid") +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, validEncryptionKey("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")) - require.False(t, validEncryptionKey(""), "empty must be invalid") - require.False(t, validEncryptionKey("short"), "short strings must be invalid") - require.False(t, validEncryptionKey("zz"+validInlineKey[2:]), "non-hex 64-char must be invalid") + 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") } // validateAuthModeExclusivity: IDP (JWKS) auth must not be enabled alongside the diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index 8abe6fc25e..aab14a85ee 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -100,7 +100,7 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - signed, err := token.SignedString([]byte(h.cfg.EncryptionKey)) + signed, err := token.SignedString([]byte(h.cfg.Auth.JWT.SecretKey)) if err != nil { return apperror.Internal.Wrap(err).WithLogMessage("failed to issue token") } diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 37ed3cc538..c1dab1fb9d 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -263,8 +263,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, mcpProxyService := service.NewMCPProxyService(mcpProxyRepo, projectRepo, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) // The single configured encryption key (ENCRYPTION_KEY) is used for all encrypted DB - // columns (secrets, subscription tokens, WebSub HMAC secrets) and for signing local - // HMAC login JWTs. It is validated below via DeriveEncryptionKey. + // columns (secrets, subscription tokens, WebSub HMAC secrets) dbEncryptionKey := cfg.EncryptionKey llmProviderDeploymentService := service.NewLLMProviderDeploymentService( llmProviderRepo, @@ -503,7 +502,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, slogger.Warn("file-based authentication is enabled — this is not recommended for production; please configure an IDP of your choice") } chain = append(chain, middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ - SecretKey: cfg.EncryptionKey, + SecretKey: cfg.Auth.JWT.SecretKey, TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, SkipValidation: false, @@ -587,7 +586,7 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m } return middleware.NewJWTAuthenticator( middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ - SecretKey: cfg.EncryptionKey, + SecretKey: cfg.Auth.JWT.SecretKey, TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, SkipValidation: cfg.Auth.JWT.SkipValidation, diff --git a/portals/ai-workspace/configs/config-platform-api-template.toml b/portals/ai-workspace/configs/config-platform-api-template.toml index cd90cab613..1fb9d8f570 100644 --- a/portals/ai-workspace/configs/config-platform-api-template.toml +++ b/portals/ai-workspace/configs/config-platform-api-template.toml @@ -16,8 +16,9 @@ # QUICK START (file-based auth mode — no external IDP needed): # 1. Copy this file to config-platform-api.toml. # 2. Set encryption_key to a 32-byte key (or use the ENCRYPTION_KEY env var): openssl rand -hex 32. -# 3. Set [auth.file_based] enabled = true and configure users. -# 4. Run: docker compose up +# 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. @@ -37,8 +38,8 @@ 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) and for signing local HMAC -# login JWTs. Provide exactly ONE of encryption_key / encryption_key_file +# (secrets, subscription tokens, WebSub HMAC secrets) +# Provide exactly ONE of encryption_key / encryption_key_file # (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. # In demo mode, if neither is set a key file is auto-created next to the database # and reused on restart; in production one of them is required. @@ -82,7 +83,7 @@ path = "/app/data/api_platform.db" [auth.jwt] enabled = true issuer = "platform-api" -# Login JWTs are signed with the top-level encryption_key (ENCRYPTION_KEY). +# secret_key = "change-me-to-a-random-secret" # or use AUTH_JWT_SECRET_KEY env var # skip_validation = false # NEVER set true in production # IDP (JWKS-based) — enable instead of JWT when using Asgardeo, Keycloak, Auth0, etc. diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index 4757a4b35a..1ce2cc8d13 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -30,8 +30,8 @@ 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) and for signing local HMAC -# login JWTs. Provide exactly ONE of encryption_key / encryption_key_file +# (secrets, subscription tokens, WebSub HMAC secrets) +# Provide exactly ONE of encryption_key / encryption_key_file # (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. # In demo mode, if neither is set a key file is auto-created next to the database # and reused on restart; in production one of them is required. @@ -58,7 +58,9 @@ path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) # --------------------------------------------------------------------------- # JWT (local HMAC) — issues signed tokens after file-based login. -# Login JWTs are signed with the top-level encryption_key (ENCRYPTION_KEY). +# secret_key is a 32-byte key (64 hex chars or base64; openssl rand -hex 32). +# Auto-generated in memory when unset (ephemeral — sessions reset on restart). +# Set AUTH_JWT_SECRET_KEY env var or secret_key here to persist sessions across restarts. [auth.jwt] enabled = true issuer = "platform-api" diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index be5777108f..455ddccd82 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -15,7 +15,8 @@ # Set the environment variables below for secrets — either export them # in your shell, or create a .env file next to this file: # -# ENCRYPTION_KEY=$(openssl rand -hex 32) # stable key: encrypts secrets & signs login JWTs +# 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 @@ -55,10 +56,11 @@ 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=${AUTH_JWT_SECRET_KEY:-} - DATABASE_PASSWORD=${DATABASE_PASSWORD:-} - APIP_DEMO_MODE=${APIP_DEMO_MODE:-true} # Single encryption key (64 hex chars = 32 bytes) for all at-rest encryption - # (secrets, subscription tokens, HMAC) and for signing local login JWTs. + # (secrets, subscription tokens, HMAC) # In demo mode, if unset a key file is auto-created next to the database and reused # on restart. Set a stable value for multi-replica/production deployments: # export ENCRYPTION_KEY=$(openssl rand -hex 32) diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index 99bd7549c4..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 the Platform API's ENCRYPTION_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 003cbf29d5..615d7d8043 100644 --- a/portals/developer-portal/distribution/docker-compose.yaml +++ b/portals/developer-portal/distribution/docker-compose.yaml @@ -27,7 +27,12 @@ # 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: +# 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. +# Set them in your shell or a .env file to persist sessions/data across restarts. When +# AUTH_JWT_SECRET_KEY is unset the Platform API uses an ephemeral in-memory demo key. +# # Set ENCRYPTION_KEY in your shell or in a .env file to persist login sessions and # encrypted data across Platform API restarts. When unset a demo key is used. # @@ -64,6 +69,7 @@ services: restart: unless-stopped 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 @@ -149,10 +155,10 @@ services: APIP_DP_LOGGING_CONSOLEONLY: "true" # Platform API — used for local auth credential validation. - # Set APIP_DP_PLATFORMAPI_JWTSECRET to the same value as the Platform API's ENCRYPTION_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 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: ${ENCRYPTION_KEY:-} + APIP_DP_PLATFORMAPI_JWTSECRET: ${AUTH_JWT_SECRET_KEY:-} APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup # AES-256-GCM key for encrypting subscription tokens and webhook secrets at rest. diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 404534affe..a343d84f9b 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 ENCRYPTION_KEY in your shell or a .env file to keep sessions and encrypted data -# alive across Platform API restarts. When unset a demo key is used. +# Set 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) in your shell or a +# .env file to keep sessions/data alive across restarts. Both are 32-byte keys (64 hex chars / +# base64 — openssl rand -hex 32). When AUTH_JWT_SECRET_KEY is unset an ephemeral demo key is used. services: platform-api: @@ -38,6 +40,7 @@ services: restart: unless-stopped 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 diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index 10c4d8f419..f3c45528b1 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 ENCRYPTION_KEY in your shell or in a .env file to persist login sessions and -# encrypted data across Platform API restarts. When unset a demo key is used. +# 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. +# Set them in your shell or a .env file to persist sessions/data across restarts. When +# AUTH_JWT_SECRET_KEY is unset the Platform API uses an ephemeral in-memory demo key. # # TLS: # A self-signed certificate is generated automatically on first start and stored @@ -68,6 +70,7 @@ services: restart: unless-stopped 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 @@ -127,10 +130,10 @@ services: APIP_DP_LOGGING_CONSOLEONLY: "true" # Platform API — used for local auth credential validation. - # Set APIP_DP_PLATFORMAPI_JWTSECRET to the same value as the Platform API's ENCRYPTION_KEY + # 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: ${ENCRYPTION_KEY:-} + APIP_DP_PLATFORMAPI_JWTSECRET: ${AUTH_JWT_SECRET_KEY:-} APIP_DP_PLATFORMAPI_INSECURE: "true" # Platform API uses a self-signed cert in this dev setup # AES-256-GCM key for encrypting subscription tokens and webhook secrets at rest. diff --git a/portals/developer-portal/docs/administer/manage-organizations.md b/portals/developer-portal/docs/administer/manage-organizations.md index f090f59ead..89e4fd00d7 100644 --- a/portals/developer-portal/docs/administer/manage-organizations.md +++ b/portals/developer-portal/docs/administer/manage-organizations.md @@ -144,11 +144,11 @@ See `configs/config-platform-api.toml.example` for the complete scope list used ### Session persistence and scripted access -The Platform API signs login JWTs with its `ENCRYPTION_KEY`. In demo mode this is auto-generated (and persisted next to the database) if unset; pin it so sessions survive restarts and set the **same value** in both services so the devportal can verify JWTs locally without a network round-trip: +The Platform API generates a random JWT signing key at startup. Sessions are invalidated when it restarts unless you pin the key. Set the **same value** in both services so the devportal can verify JWTs locally without a network round-trip: ```bash # In .env (read by both services via docker-compose env_file / APIP_DP_* override) -ENCRYPTION_KEY=<64-hex-char-string> # openssl rand -hex 32 +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: @@ -160,7 +160,7 @@ TOKEN=$(curl -sk -X POST "https://localhost:9243/api/portal/v0.9/auth/login" \ curl -sk -H "Authorization: Bearer $TOKEN" https://localhost:3000/api/v0.9/organizations ``` -The token is verified locally by the Developer Portal using the shared `ENCRYPTION_KEY` (the Platform API's signing key) with no extra call to the Platform API per request. +The token is verified locally by the Developer Portal using the shared `AUTH_JWT_SECRET_KEY` with no extra call to the Platform API per request. > **Note:** Local auth is for development only. For production, configure the global OIDC identity provider via `APIP_DP_IDP_*` environment variables. diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 4e901e2bf8..d594966803 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -10,13 +10,14 @@ services: - DATABASE_PATH=/app/data/platform.db - DATABASE_EXECUTE_SCHEMA_DDL=true # Single encryption key — must be 32 bytes (64 hex chars). Used for all at-rest - # encryption (secrets, subscription tokens, HMAC) and for signing local login JWTs. + # 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=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 e3f4da36e4..e3901099fc 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -53,13 +53,14 @@ services: - DATABASE_PASSWORD=${MSSQL_PASSWORD:-Strong!Passw0rd} - DATABASE_SSL_MODE=disable # Single encryption key — must be 32 bytes (64 hex chars). Used for all at-rest - # encryption (secrets, subscription tokens, HMAC) and for signing local login JWTs. + # 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=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 9ff88eae5e..96f817d621 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -53,8 +53,6 @@ services: - DATABASE_USER=apip - DATABASE_PASSWORD=apip - DATABASE_SSL_MODE=disable - # Single encryption key — must be 32 bytes (64 hex chars). Used for all at-rest - # encryption (secrets, subscription tokens, HMAC) and for signing local login JWTs. - 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). @@ -63,6 +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=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). @@ -230,9 +229,9 @@ services: - APIP_DP_ORGANIZATION_AUTOCREATESUBSCRIPTIONPLANS=false - APIP_DP_LOGGING_CONSOLEONLY=true # Validate platform-api-issued JWTs locally: this MUST equal the stack's - # ENCRYPTION_KEY so the admin token is accepted by the devportal. + # 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=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + - 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 From 8661da6d6b82c573e5f8c1df9bd0357b3f340f1b Mon Sep 17 00:00:00 2001 From: thivindu Date: Fri, 10 Jul 2026 21:23:47 +0530 Subject: [PATCH 5/8] Remove ENCRYPTION_KEY_FILE config and make ENCRYPTION_KEY and AUTH_JWT_SECRET_KEY required --- docs/ai-workspace/configuration.md | 2 +- .../features/secrets-management.md | 4 +- platform-api/README.md | 12 +- platform-api/config/config.go | 213 +------------- platform-api/config/config.toml | 6 +- platform-api/config/config_test.go | 264 +++--------------- .../internal/integration/harness_test.go | 6 +- .../repository/api_deployments_test.go | 4 +- portals/ai-workspace/README.md | 2 +- .../configs/config-platform-api-template.toml | 8 +- .../configs/config-platform-api.toml | 6 +- portals/ai-workspace/docker-compose.yaml | 13 +- portals/ai-workspace/production/README.md | 2 +- .../distribution/docker-compose.yaml | 11 +- .../docker-compose.platform-api.yaml | 12 +- portals/developer-portal/docker-compose.yaml | 8 +- .../it/docker-compose.test.postgres.yaml | 3 +- .../it/docker-compose.test.yaml | 3 +- 18 files changed, 98 insertions(+), 481 deletions(-) diff --git a/docs/ai-workspace/configuration.md b/docs/ai-workspace/configuration.md index 9b65f0ca48..8aa615de21 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -92,7 +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 (or use `ENCRYPTION_KEY_FILE`) | +| `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 c24b275b91..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 | |---------|-------------| -| `ENCRYPTION_KEY` | 32-byte AES-256 key as 64 hex characters or base64. In demo mode, if unset a key file is auto-generated next to the database and reused on restart; in production it is required. Mutually exclusive with `ENCRYPTION_KEY_FILE`. | +| `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: @@ -358,4 +358,4 @@ Then copy the output value into your `.env` file: ENCRYPTION_KEY=a3f1e2d4b5c6... ``` -> **Warning:** Always set a stable `ENCRYPTION_KEY` (or `ENCRYPTION_KEY_FILE`) 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 acb21faee4..c6a118a9ca 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -241,9 +241,8 @@ 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 encryption key — you must set `ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE` — and it -> warns loudly if `AUTH_JWT_SKIP_VALIDATION=true`. +> production-grade startup checks. Note that `ENCRYPTION_KEY` and `AUTH_JWT_SECRET_KEY` are **required** +> With demo mode off, the server warns loudly if `AUTH_JWT_SKIP_VALIDATION=true`. --- @@ -374,14 +373,11 @@ In **IDP mode with `AUTH_IDP_VALIDATION_MODE=role`**, IDP roles are resolved fro ### Encryption -A single key protects all at-rest encryption (secrets, subscription tokens, WebSub HMAC secrets). Provide **exactly one** of `ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE`. +`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)_ | 32-byte AES-256 key as 64 hex chars or base64 (32 bytes). Generate with `openssl rand -hex 32`. | -| `ENCRYPTION_KEY_FILE` | _(empty)_ | Path to a 32-byte binary key file (read on every start). Mutually exclusive with `ENCRYPTION_KEY`. | - -In **demo mode** (default), if neither is set a key file is auto-generated next to the SQLite database (`/secret-encryption.key`) and reused on restart. In **production** (`APIP_DEMO_MODE=false`), one of the two must be provided or startup fails — a key is never auto-generated. +| `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 246ad07394..80745f3be7 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -18,15 +18,11 @@ package config import ( - "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" - "errors" "fmt" "log/slog" - "os" - "path/filepath" "reflect" "strings" "sync" @@ -86,8 +82,7 @@ type Server struct { OpenAPISpecPath string `koanf:"openapi_spec_path"` LLMTemplateDefinitionsPath string `koanf:"llm_template_definitions_path"` - EncryptionKey string `koanf:"encryption_key"` - EncryptionKeyFile string `koanf:"encryption_key_file"` + EncryptionKey string `koanf:"encryption_key"` Database Database `koanf:"database"` Auth Auth `koanf:"auth"` @@ -364,116 +359,27 @@ func LoadConfig(configPath string) (*Server, error) { } if cfg.Auth.JWT.Enabled { - switch { - case 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 one with: openssl rand -hex 32", - ) - } - // Demo: generate an ephemeral in-memory key (a valid 64-hex/32-byte value) - 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 in-memory demo key; " + - "login sessions will be invalidated on restart. Set AUTH_JWT_SECRET_KEY to persist sessions.") - case !valid32ByteKey(cfg.Auth.JWT.SecretKey): + 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") + } + 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 err := resolveEncryptionKey(cfg); err != nil { - return nil, err + 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 } -// resolveEncryptionKey resolves cfg.EncryptionKey from either the inline ENCRYPTION_KEY or the -// ENCRYPTION_KEY_FILE (a 32-byte binary key file). The two sources are mutually exclusive, and -// the key is re-read on every start/restart so a persisted key keeps encrypted data readable. -// -// Rules: -// - Both provided → error (configure exactly one). -// - Inline ENCRYPTION_KEY → validated (64 hex or base64→32 bytes); never written to a file. -// - ENCRYPTION_KEY_FILE only → read + validated on every start; never auto-generated. -// - Neither, non-demo mode → error (a key is never auto-generated in production). -// - Neither, demo mode → key file path defaults alongside the database file; the key -// is generated on first run and reloaded on restart. If no path can be derived, an ephemeral -// key is used and encrypted data will not survive a restart. -func resolveEncryptionKey(cfg *Server) error { - // Mutual exclusivity: never accept both an inline key and a key file. - if cfg.EncryptionKey != "" && cfg.EncryptionKeyFile != "" { - return fmt.Errorf("configure only one of ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, not both") - } - - switch { - case cfg.EncryptionKey != "": - // Inline key from config.toml / ENCRYPTION_KEY. Validate; never persist to a file. - if !valid32ByteKey(cfg.EncryptionKey) { - return fmt.Errorf("invalid ENCRYPTION_KEY: must be 64 hex characters or base64 " + - "decoding to 32 bytes (generate one with: openssl rand -hex 32)") - } - return nil - - case cfg.EncryptionKeyFile != "": - // Explicit key file: read and validate on every start/restart. Never auto-generate. - hexKey, err := loadEncryptionKeyFile(cfg.EncryptionKeyFile) - if err != nil { - return fmt.Errorf("failed to load ENCRYPTION_KEY_FILE: %w", err) - } - cfg.EncryptionKey = hexKey - return nil - - default: - // Neither provided. - if !demoMode() { - return fmt.Errorf("no encryption key configured. Set ENCRYPTION_KEY or " + - "ENCRYPTION_KEY_FILE when APIP_DEMO_MODE=false (generate one with: openssl rand -hex 32)") - } - - // Demo mode: default the key file path alongside the database file so the generated key - // is persisted and reloaded on restart (encrypted data survives restarts). - if cfg.EncryptionKeyFile == "" && cfg.Database.Path != "" { - cfg.EncryptionKeyFile = filepath.Join(filepath.Dir(cfg.Database.Path), "secret-encryption.key") - } - - if cfg.EncryptionKeyFile == "" { - // No path available to persist (e.g. a non-SQLite driver with no DB path) — fall back - // to an ephemeral key. Encrypted data will not survive a restart. - key, err := generateRandomSecret() - if err != nil { - return fmt.Errorf("failed to generate ephemeral encryption key: %w", err) - } - cfg.EncryptionKey = key - slog.Warn("APIP_DEMO_MODE: using an ephemeral random encryption key (no key file path to " + - "persist) — encrypted secrets, subscription tokens, and login sessions will be unusable " + - "after restart. Set ENCRYPTION_KEY or ENCRYPTION_KEY_FILE to persist across restarts.") - return nil - } - - hexKey, err := loadOrGenerateEncryptionKeyFile(cfg.EncryptionKeyFile) - if err != nil { - // Could not create/read the key file — fall back to an ephemeral key in demo mode. - slog.Warn("APIP_DEMO_MODE: could not initialise encryption key file, falling back to an "+ - "ephemeral key (encrypted data will not survive a restart)", - slog.String("path", cfg.EncryptionKeyFile), slog.Any("err", err)) - key, genErr := generateRandomSecret() - if genErr != nil { - return fmt.Errorf("failed to generate ephemeral encryption key: %w", genErr) - } - cfg.EncryptionKey = key - return nil - } - cfg.EncryptionKey = hexKey - return nil - } -} - // 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 { @@ -488,99 +394,6 @@ func valid32ByteKey(keyStr string) bool { return false } -func generateRandomSecret() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - 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 == "" { - return true - } - return v == "true" || v == "1" -} - -const encryptionKeySize = 32 // AES-256 - -// loadOrGenerateEncryptionKeyFile 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: generateEncryptionKeyFile uses O_CREATE|O_EXCL so -// only one writer succeeds; others see os.ErrExist and fall through to loadEncryptionKeyFile. -func loadOrGenerateEncryptionKeyFile(filePath string) (string, error) { - err := generateEncryptionKeyFile(filePath) - switch { - case err == nil: - slog.Info("APIP_DEMO_MODE: generated and persisted encryption key — encrypted data will survive restarts", - slog.String("path", filePath), - slog.String("hint", "Set and provide ENCRYPTION_KEY or a shared ENCRYPTION_KEY_FILE 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 loadEncryptionKeyFile(filePath) -} - -// generateEncryptionKeyFile 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 generateEncryptionKeyFile(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, encryptionKeySize) - 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 -} - -// loadEncryptionKeyFile reads the key file, validates its size (32 raw bytes), warns if -// world-readable, and returns the key as a 64-char hex string. -func loadEncryptionKeyFile(filePath string) (string, error) { - info, err := os.Stat(filePath) - if err != nil { - return "", fmt.Errorf("failed to stat encryption key file %s: %w", filePath, err) - } - if info.Mode().Perm()&0004 != 0 { - slog.Warn("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 encryption key file %s: %w", filePath, err) - } - if len(data) != encryptionKeySize { - return "", fmt.Errorf("encryption key file %s has wrong size: expected %d bytes, got %d", filePath, encryptionKeySize, len(data)) - } - slog.Info("Loaded persisted encryption key from file", slog.String("path", filePath)) - return hex.EncodeToString(data), nil -} - // envToKoanfKey maps a lowercased environment variable name to its koanf dot-notation key. // Returns "" for unknown variables, which causes koanf to skip them. // Supports both the current env var names (e.g. DATABASE_DB_PATH) and the legacy @@ -604,8 +417,6 @@ func envToKoanfKey(s string) string { return "enable_scope_validation" case "encryption_key": return "encryption_key" - case "encryption_key_file": - return "encryption_key_file" // Database case "database_driver": diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index c626259be5..b4d16ae359 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -29,12 +29,8 @@ port = "9243" # --------------------------------------------------------------------------- # Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption # (secrets, subscription tokens, WebSub HMAC secrets) -# Provide exactly ONE of encryption_key / encryption_key_file -# (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. -# In demo mode, if neither is set a key file is auto-created next to the database -# and reused on restart; in production one of them is required. +# Env: ENCRYPTION_KEY. Generate with: openssl rand -hex 32. # encryption_key = "" -# encryption_key_file = "" # --------------------------------------------------------------------------- # Database diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 553f24d6ad..a44119ef7e 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -19,263 +19,73 @@ package config import ( - "encoding/hex" "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// A valid inline encryption key: 64 hex chars decoding to 32 bytes. -const validInlineKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" +// Valid 32-byte keys encoded as 64 hex chars. +const ( + validInlineKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + validJWTKey = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +) -// clearKeyEnv resets all encryption-related env vars to empty so each test starts clean. -// A JWT signing key is provided so the (separate) AUTH_JWT_SECRET_KEY requirement never -// interferes with these encryption-key assertions — JWT is validated independently. -// t.Setenv restores the previous value automatically at test end. -func clearKeyEnv(t *testing.T) { +// 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", "") - t.Setenv("ENCRYPTION_KEY_FILE", "") - t.Setenv("DATABASE_DB_PATH", "") + t.Setenv("ENCRYPTION_KEY", validInlineKey) + t.Setenv("AUTH_JWT_SECRET_KEY", validJWTKey) t.Setenv("APIP_DEMO_MODE", "") - t.Setenv("AUTH_JWT_SECRET_KEY", validInlineKey) } -// writeValidKeyFile writes a 32-byte binary key file and returns its path and the -// expected hex-encoded key value. -func writeValidKeyFile(t *testing.T, dir, name string) (path, hexKey string) { - t.Helper() - key := make([]byte, 32) - for i := range key { - key[i] = byte(i + 1) - } - path = filepath.Join(dir, name) - require.NoError(t, os.WriteFile(path, key, 0600)) - return path, hex.EncodeToString(key) -} - -// setDemoDBPath points DATABASE_DB_PATH at a fresh temp file and returns the default -// key-file path (alongside the DB) that demo-mode resolution would use. -func setDemoDBPath(t *testing.T) (defaultKeyFile string) { - t.Helper() - dir := t.TempDir() - t.Setenv("DATABASE_DB_PATH", filepath.Join(dir, "api_platform.db")) - return filepath.Join(dir, "secret-encryption.key") -} - -// --- Demo mode --- - -// 1.i — Demo, neither provided, DB path present → a key is generated, persisted to the default -// key file, and reloaded (identical) on the next start. -func TestResolveKey_Demo_NeitherProvided_GeneratesAndPersists(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "true") - keyFile := setDemoDBPath(t) - - cfg1, err := LoadConfig("") - require.NoError(t, err) - require.NotEmpty(t, cfg1.EncryptionKey) - assert.Equal(t, keyFile, cfg1.EncryptionKeyFile, "key file path must default alongside the DB") - - data, readErr := os.ReadFile(keyFile) - require.NoError(t, readErr, "key file must be created on first start") - assert.Len(t, data, 32, "key file must contain exactly 32 bytes") - - cfg2, err := LoadConfig("") - require.NoError(t, err) - assert.Equal(t, cfg1.EncryptionKey, cfg2.EncryptionKey, - "the persisted key must be reloaded identically on restart") -} - -// 1.i (edge) — Demo, neither provided, no DB path → falls back to an ephemeral key that differs -// per call (nothing to persist). Exercised directly since empty env values can't clear the -// default Database.Path (koanf skips empty env values). -func TestResolveKey_Demo_NeitherProvided_NoDBPath_Ephemeral(t *testing.T) { - t.Setenv("APIP_DEMO_MODE", "true") - - cfg1 := &Server{} // no EncryptionKey, no EncryptionKeyFile, empty Database.Path - require.NoError(t, resolveEncryptionKey(cfg1)) - cfg2 := &Server{} - require.NoError(t, resolveEncryptionKey(cfg2)) - - require.NotEmpty(t, cfg1.EncryptionKey) - assert.Empty(t, cfg1.EncryptionKeyFile, "no key file path can be derived without a DB path") - assert.NotEqual(t, cfg1.EncryptionKey, cfg2.EncryptionKey, - "without a persistable path, demo keys must be ephemeral and differ per call") -} - -// 1.ii — Demo, only ENCRYPTION_KEY (valid) → used as-is; never written to the key file. -func TestResolveKey_Demo_InlineKeyValid_NotPersisted(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "true") - keyFile := setDemoDBPath(t) - t.Setenv("ENCRYPTION_KEY", validInlineKey) +// 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) assert.Equal(t, validInlineKey, cfg.EncryptionKey) - - _, statErr := os.Stat(keyFile) - assert.True(t, os.IsNotExist(statErr), "an inline key must never be written to the key file") -} - -// 1.ii — Demo, only ENCRYPTION_KEY (invalid) → error, no fallback. -func TestResolveKey_Demo_InlineKeyInvalid_Errors(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "true") - t.Setenv("ENCRYPTION_KEY", "not-a-valid-32-byte-key") - - _, err := LoadConfig("") - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid ENCRYPTION_KEY") -} - -// 1.iii — Demo, only ENCRYPTION_KEY_FILE (valid) → read from file and used. -func TestResolveKey_Demo_KeyFileValid_Used(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "true") - path, expected := writeValidKeyFile(t, t.TempDir(), "my.key") - t.Setenv("ENCRYPTION_KEY_FILE", path) - - cfg, err := LoadConfig("") - require.NoError(t, err) - assert.Equal(t, expected, cfg.EncryptionKey) -} - -// 1.iii — Demo, only ENCRYPTION_KEY_FILE (wrong size) → error, never auto-generated. -func TestResolveKey_Demo_KeyFileInvalidSize_Errors(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "true") - dir := t.TempDir() - path := filepath.Join(dir, "bad.key") - require.NoError(t, os.WriteFile(path, []byte("too-short"), 0600)) - t.Setenv("ENCRYPTION_KEY_FILE", path) - - _, err := LoadConfig("") - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to load ENCRYPTION_KEY_FILE") } -// 1.iii — Demo, only ENCRYPTION_KEY_FILE (missing) → error, never auto-generated at that path. -func TestResolveKey_Demo_KeyFileMissing_Errors(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "true") - t.Setenv("ENCRYPTION_KEY_FILE", filepath.Join(t.TempDir(), "does-not-exist.key")) - - _, err := LoadConfig("") - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to load ENCRYPTION_KEY_FILE") -} - -// 1.iv / 2.iv — Both provided → error in demo mode. -func TestResolveKey_Demo_BothProvided_Errors(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "true") - path, _ := writeValidKeyFile(t, t.TempDir(), "my.key") - t.Setenv("ENCRYPTION_KEY", validInlineKey) - t.Setenv("ENCRYPTION_KEY_FILE", path) - - _, err := LoadConfig("") - require.Error(t, err) - assert.Contains(t, err.Error(), "only one of ENCRYPTION_KEY or ENCRYPTION_KEY_FILE") -} - -// --- Non-demo (production) mode --- - -// 2.i — Non-demo, neither provided → fatal error; never auto-generated. -func TestResolveKey_NonDemo_NeitherProvided_Errors(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "false") - setDemoDBPath(t) // even with a DB path, non-demo must not generate. +// 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", "") _, err := LoadConfig("") require.Error(t, err) - assert.Contains(t, err.Error(), "no encryption key configured") -} - -// 2.ii — Non-demo, only ENCRYPTION_KEY (valid) → used. -func TestResolveKey_NonDemo_InlineKeyValid_Used(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "false") - t.Setenv("ENCRYPTION_KEY", validInlineKey) - - cfg, err := LoadConfig("") - require.NoError(t, err) - assert.Equal(t, validInlineKey, cfg.EncryptionKey) + assert.Contains(t, err.Error(), "ENCRYPTION_KEY is required") } -// 2.ii — Non-demo, only ENCRYPTION_KEY (invalid) → error. -func TestResolveKey_NonDemo_InlineKeyInvalid_Errors(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "false") - t.Setenv("ENCRYPTION_KEY", "short") +// 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") _, err := LoadConfig("") require.Error(t, err) assert.Contains(t, err.Error(), "invalid ENCRYPTION_KEY") } -// 2.iii — Non-demo, only ENCRYPTION_KEY_FILE (valid) → read and used. -func TestResolveKey_NonDemo_KeyFileValid_Used(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "false") - path, expected := writeValidKeyFile(t, t.TempDir(), "prod.key") - t.Setenv("ENCRYPTION_KEY_FILE", path) - - cfg, err := LoadConfig("") - require.NoError(t, err) - assert.Equal(t, expected, cfg.EncryptionKey) -} - -// 2.iv — Both provided → error in non-demo mode. -func TestResolveKey_NonDemo_BothProvided_Errors(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "false") - path, _ := writeValidKeyFile(t, t.TempDir(), "prod.key") - t.Setenv("ENCRYPTION_KEY", validInlineKey) - t.Setenv("ENCRYPTION_KEY_FILE", path) +// 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", "") _, err := LoadConfig("") require.Error(t, err) - assert.Contains(t, err.Error(), "only one of ENCRYPTION_KEY or ENCRYPTION_KEY_FILE") -} - -// --- APIP_DEMO_MODE parsing --- - -// APIP_DEMO_MODE unset → defaults to demo, so neither-provided generates a key. -func TestResolveKey_DemoModeUnset_DefaultsToDemo(t *testing.T) { - clearKeyEnv(t) - os.Unsetenv("APIP_DEMO_MODE") - setDemoDBPath(t) - - cfg, err := LoadConfig("") - require.NoError(t, err) - assert.NotEmpty(t, cfg.EncryptionKey) -} - -// APIP_DEMO_MODE="1" and whitespace-padded values are treated as truthy (demo). -func TestResolveKey_DemoModeTruthyVariants(t *testing.T) { - for _, v := range []string{"1", " true "} { - t.Run(v, func(t *testing.T) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", v) - setDemoDBPath(t) - - cfg, err := LoadConfig("") - require.NoError(t, err) - assert.NotEmpty(t, cfg.EncryptionKey) - }) - } + assert.Contains(t, err.Error(), "AUTH_JWT_SECRET_KEY is required") } // 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) { - clearKeyEnv(t) - t.Setenv("APIP_DEMO_MODE", "true") - t.Setenv("ENCRYPTION_KEY", validInlineKey) // valid, so the failure is attributable to the JWT key + setValidKeys(t) t.Setenv("AUTH_JWT_SECRET_KEY", "not-a-valid-32-byte-key") _, err := LoadConfig("") @@ -294,6 +104,18 @@ func TestValid32ByteKey(t *testing.T) { require.False(t, valid32ByteKey("zz"+validInlineKey[2:]), "non-hex 64-char must be invalid") } +// 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() { + for _, v := range []string{ + "ENCRYPTION_KEY", + "AUTH_JWT_SECRET_KEY", + "APIP_DEMO_MODE", + } { + os.Unsetenv(v) + } +} + // validateAuthModeExclusivity: IDP (JWKS) auth must not be enabled alongside the // local JWT or file-based modes — the server must fail fast so operators turn the // local modes off consciously and all tokens are validated against the IDP JWKS. diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index d9cd4828e5..0e5cd0129f 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -41,9 +41,11 @@ import ( ) func TestMain(m *testing.M) { - // Allow GetConfig() to auto-provision an encryption key so tests that exercise - // subscription_repository.go don't fail 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 ae18d9bf58..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 auto-provision an 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/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 97cde52665..99f591ba38 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -421,7 +421,7 @@ 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 (or persists one next to the DB) when none is set | A stable key is **required** (`ENCRYPTION_KEY` or `ENCRYPTION_KEY_FILE`) | +| **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: diff --git a/portals/ai-workspace/configs/config-platform-api-template.toml b/portals/ai-workspace/configs/config-platform-api-template.toml index 1fb9d8f570..c84fa22e33 100644 --- a/portals/ai-workspace/configs/config-platform-api-template.toml +++ b/portals/ai-workspace/configs/config-platform-api-template.toml @@ -39,12 +39,8 @@ enable_scope_validation = true # --------------------------------------------------------------------------- # Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption # (secrets, subscription tokens, WebSub HMAC secrets) -# Provide exactly ONE of encryption_key / encryption_key_file -# (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. -# In demo mode, if neither is set a key file is auto-created next to the database -# and reused on restart; in production one of them is required. +# Env: ENCRYPTION_KEY. Generate with: openssl rand -hex 32. # encryption_key = "" -# encryption_key_file = "" # Controls authentication for POST /api/v0.9/organizations. # When true, the endpoint requires both a valid Bearer JWT and the @@ -71,8 +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 is configured once at the top of this file via encryption_key / -# encryption_key_file — it covers subscription tokens, secrets, and HMAC secrets.) # --------------------------------------------------------------------------- # Authentication diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index 1ce2cc8d13..e2f02083bc 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -31,12 +31,8 @@ enable_scope_validation = true # --------------------------------------------------------------------------- # Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption # (secrets, subscription tokens, WebSub HMAC secrets) -# Provide exactly ONE of encryption_key / encryption_key_file -# (env: ENCRYPTION_KEY / ENCRYPTION_KEY_FILE). Generate with: openssl rand -hex 32. -# In demo mode, if neither is set a key file is auto-created next to the database -# and reused on restart; in production one of them is required. +# Env: ENCRYPTION_KEY. Generate with: openssl rand -hex 32. # encryption_key = "" -# encryption_key_file = "" # --------------------------------------------------------------------------- # Database diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index 455ddccd82..75f7b62ccc 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -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=${AUTH_JWT_SECRET_KEY:-} + # 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:?AUTH_JWT_SECRET_KEY is required — a 32-byte key (openssl rand -hex 32)} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required — a 32-byte key (openssl rand -hex 32)} - DATABASE_PASSWORD=${DATABASE_PASSWORD:-} - APIP_DEMO_MODE=${APIP_DEMO_MODE:-true} - # Single encryption key (64 hex chars = 32 bytes) for all at-rest encryption - # (secrets, subscription tokens, HMAC) - # In demo mode, if unset a key file is auto-created next to the database and reused - # on restart. Set a stable value for multi-replica/production deployments: - # export ENCRYPTION_KEY=$(openssl rand -hex 32) - - ENCRYPTION_KEY=${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 d1cd035512..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 `ENCRYPTION_KEY` (or `ENCRYPTION_KEY_FILE`). +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/distribution/docker-compose.yaml b/portals/developer-portal/distribution/docker-compose.yaml index 615d7d8043..904e8d86fd 100644 --- a/portals/developer-portal/distribution/docker-compose.yaml +++ b/portals/developer-portal/distribution/docker-compose.yaml @@ -30,11 +30,8 @@ # 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. -# Set them in your shell or a .env file to persist sessions/data across restarts. When -# AUTH_JWT_SECRET_KEY is unset the Platform API uses an ephemeral in-memory demo key. -# -# Set ENCRYPTION_KEY in your shell or in a .env file to persist login sessions and -# encrypted data across Platform API restarts. When unset a demo key is used. +# 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. @@ -69,8 +66,8 @@ services: restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} + - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY is required — a 32-byte key (openssl rand -hex 32)} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required — a 32-byte key (openssl rand -hex 32)} 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 a343d84f9b..6be3f182fe 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -28,10 +28,10 @@ # 4. Open http://localhost:3000 # Login: admin / admin # -# Set 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) in your shell or a -# .env file to keep sessions/data alive across restarts. Both are 32-byte keys (64 hex chars / -# base64 — openssl rand -hex 32). When AUTH_JWT_SECRET_KEY is unset an ephemeral demo key is used. +# 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: @@ -40,8 +40,8 @@ services: restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} + - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY is required — a 32-byte key (openssl rand -hex 32)} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required — a 32-byte key (openssl rand -hex 32)} 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 f3c45528b1..37d58d0774 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -36,8 +36,8 @@ # 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. -# Set them in your shell or a .env file to persist sessions/data across restarts. When -# AUTH_JWT_SECRET_KEY is unset the Platform API uses an ephemeral in-memory demo key. +# 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 @@ -70,8 +70,8 @@ services: restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} + - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY is required — a 32-byte key (openssl rand -hex 32)} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required — a 32-byte key (openssl rand -hex 32)} 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/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 From 0420d722a8e3ba6ccc2c19f98212a758755bbc45 Mon Sep 17 00:00:00 2001 From: thivindu Date: Fri, 10 Jul 2026 22:19:40 +0530 Subject: [PATCH 6/8] Fix UI test --- .../003-llm-proxy-secret-management.cy.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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'); From f4cc3221dfdddc0e7b4c9c06ceb6f67a420ec999 Mon Sep 17 00:00:00 2001 From: thivindu Date: Fri, 10 Jul 2026 22:30:21 +0530 Subject: [PATCH 7/8] Fix comment inconsistencies --- platform-api/README.md | 5 ++--- platform-api/config/config.toml | 3 +-- portals/ai-workspace/configs/config-platform-api.toml | 3 +-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/platform-api/README.md b/platform-api/README.md index c6a118a9ca..4154df88dc 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -241,8 +241,7 @@ 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. Note that `ENCRYPTION_KEY` and `AUTH_JWT_SECRET_KEY` are **required** -> With demo mode off, the server warns loudly if `AUTH_JWT_SKIP_VALIDATION=true`. +> production-grade startup checks. Note that `ENCRYPTION_KEY` and `AUTH_JWT_SECRET_KEY` are **required**. --- @@ -252,7 +251,7 @@ The server signs and validates HMAC login tokens using `AUTH_JWT_SECRET_KEY` — | 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`). Required in production; demo generates an ephemeral one. | +| `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` | diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index b4d16ae359..7b29af97f5 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -53,8 +53,7 @@ driver = "sqlite3" # "sqlite3" or "postgres" # JWT (local HMAC) — issues signed tokens after file-based login. # secret_key is a 32-byte key (64 hex chars or base64; openssl rand -hex 32) -# Auto-generated in memory when unset (ephemeral — sessions reset on restart). -# Set AUTH_JWT_SECRET_KEY env var or secret_key here to persist sessions across restarts. +# Env: AUTH_JWT_SECRET_KEY. Generate with: openssl rand -hex 32. [auth.jwt] enabled = true issuer = "platform-api" diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index e2f02083bc..690a323bfa 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -55,8 +55,7 @@ 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 a 32-byte key (64 hex chars or base64; openssl rand -hex 32). -# Auto-generated in memory when unset (ephemeral — sessions reset on restart). -# Set AUTH_JWT_SECRET_KEY env var or secret_key here to persist sessions across restarts. +# Set AUTH_JWT_SECRET_KEY env var Generate with: openssl rand -hex 32. [auth.jwt] enabled = true issuer = "platform-api" From cb02c66b3d75e59271aa1f5add9bdc92591ab32c Mon Sep 17 00:00:00 2001 From: thivindu Date: Sat, 11 Jul 2026 18:39:06 +0530 Subject: [PATCH 8/8] Fix docker compose files --- distribution/all-in-one/docker-compose.yaml | 6 +++--- portals/ai-workspace/docker-compose.yaml | 4 ++-- portals/developer-portal/distribution/docker-compose.yaml | 4 ++-- portals/developer-portal/docker-compose.platform-api.yaml | 4 ++-- portals/developer-portal/docker-compose.yaml | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml index d8e9db8abf..d4dbb4ab05 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -41,7 +41,7 @@ services: dockerfile: Dockerfile container_name: devportal environment: - - APIP_DP_PLATFORMAPI_JWTSECRET=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY must be set to a stable 32-byte key (openssl rand -hex 32)} + - APIP_DP_PLATFORMAPI_JWTSECRET:${AUTH_JWT_SECRET_KEY:-} ports: - "3001:3001" volumes: @@ -75,8 +75,8 @@ services: - DATABASE_MAX_IDLE_CONNS=10 - DATABASE_CONN_MAX_LIFETIME=300 - DATABASE_EXECUTE_SCHEMA_DDL=true - - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY must be set to a stable 32-byte key (openssl rand -hex 32)} - - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY must be set to a stable 32-byte key (openssl rand -hex 32)} + - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} depends_on: postgres: condition: service_healthy diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index 75f7b62ccc..1146eb717a 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -60,8 +60,8 @@ services: # 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:?AUTH_JWT_SECRET_KEY is required — a 32-byte key (openssl rand -hex 32)} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required — a 32-byte key (openssl rand -hex 32)} + - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:-} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} - DATABASE_PASSWORD=${DATABASE_PASSWORD:-} - APIP_DEMO_MODE=${APIP_DEMO_MODE:-true} # ── OIDC IDP — uncomment to validate tokens against any OIDC provider's diff --git a/portals/developer-portal/distribution/docker-compose.yaml b/portals/developer-portal/distribution/docker-compose.yaml index 904e8d86fd..bb278034da 100644 --- a/portals/developer-portal/distribution/docker-compose.yaml +++ b/portals/developer-portal/distribution/docker-compose.yaml @@ -66,8 +66,8 @@ services: restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY is required — a 32-byte key (openssl rand -hex 32)} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required — a 32-byte key (openssl rand -hex 32)} + - 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 6be3f182fe..6c4bdc9b0b 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -40,8 +40,8 @@ services: restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY is required — a 32-byte key (openssl rand -hex 32)} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required — a 32-byte key (openssl rand -hex 32)} + - 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 37d58d0774..da21f42467 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -70,8 +70,8 @@ services: restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: - - AUTH_JWT_SECRET_KEY=${AUTH_JWT_SECRET_KEY:?AUTH_JWT_SECRET_KEY is required — a 32-byte key (openssl rand -hex 32)} - - ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required — a 32-byte key (openssl rand -hex 32)} + - 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