Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions gateway/gateway-controller/cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/wso2/api-platform/common/eventhub"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/adminserver"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/apikeyxds"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/devportalwebhook"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/encryption/aesgcm"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/eventlistener"
Expand Down Expand Up @@ -514,6 +515,39 @@ func main() {
if cfg.Controller.Metrics.Enabled {
router.Use(middleware.MetricsMiddleware())
}
// Register the devportal webhook endpoint before auth middleware so it uses its own
// HMAC-based authentication rather than the gateway management API auth.
if cfg.DevPortalWebhook.Enabled {
rsaKey, loadErr := devportalwebhook.LoadRSAPrivateKey(cfg.DevPortalWebhook.PrivateKeyPath)
if loadErr != nil {
log.Error("Failed to load devportal webhook RSA private key",
slog.String("path", cfg.DevPortalWebhook.PrivateKeyPath),
slog.Any("error", loadErr))
os.Exit(1)
}

idempCache := devportalwebhook.NewIdempotencyCache(
cfg.DevPortalWebhook.Idempotency.TTL,
cfg.DevPortalWebhook.Idempotency.MaxSize,
)
defer idempCache.Close()

webhookHandler := devportalwebhook.NewHandler(devportalwebhook.HandlerConfig{
Secret: []byte(cfg.DevPortalWebhook.Secret),
PrivateKey: rsaKey,
GatewayType: cfg.DevPortalWebhook.GatewayType,
Cache: idempCache,
APIKeyMgr: apiKeyXDSManager,
DB: db,
EventHub: eventHubInstance,
GatewayID: gatewayID,
Logger: log,
})

router.POST("/webhooks/devportal", webhookHandler.HandleWebhook)
log.Info("Devportal webhook listener registered", slog.String("path", "/webhooks/devportal"))
}

authConfig := generateAuthConfig(cfg)
authMiddleWare, err := authenticators.AuthMiddleware(authConfig, log)
if err != nil {
Expand Down
62 changes: 62 additions & 0 deletions gateway/gateway-controller/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ type Config struct {
// When nil, subscription validation system policy remains disabled.
Subscriptions *SubscriptionsConfig `koanf:"subscriptions"`
ImmutableGateway ImmutableGatewayConfig `koanf:"immutable_gateway"`
// DevPortalWebhook holds configuration for the inbound developer portal webhook listener.
DevPortalWebhook DevPortalWebhookConfig `koanf:"devportal_webhook"`
}

// AnalyticsConfig holds analytics configuration
Expand Down Expand Up @@ -81,6 +83,34 @@ type ImmutableGatewayConfig struct {
ArtifactsDir string `koanf:"artifacts_dir"`
}

// DevPortalWebhookConfig holds configuration for the inbound developer portal webhook listener.
// When Enabled is true, POST /webhooks/devportal receives signed events from the devportal
// and translates them into gateway-side API key and subscription state changes.
type DevPortalWebhookConfig struct {
// Enabled controls whether the webhook endpoint is registered. Default: false.
Enabled bool `koanf:"enabled"`
// Secret is the HMAC-SHA256 shared secret agreed with the devportal. Required when enabled.
Secret string `koanf:"secret"`
// PrivateKeyPath is the path to the PEM-encoded RSA private key used to decrypt
// API key secrets sent by the devportal. Required when enabled.
PrivateKeyPath string `koanf:"private_key_path"`
// GatewayType filters events by the gateway_type field in the event envelope.
// Events with a different gateway_type are silently accepted (200) but not processed.
// Default: "" (process events for all gateway types).
GatewayType string `koanf:"gateway_type"`
// Idempotency controls the in-memory event deduplication cache.
Idempotency DevPortalIdempotencyConfig `koanf:"idempotency"`
}

// DevPortalIdempotencyConfig controls the in-memory event deduplication cache used by the
// devportal webhook listener to prevent duplicate processing on delivery retries.
type DevPortalIdempotencyConfig struct {
// TTL is how long a processed event_id is retained. Default: 10m.
TTL time.Duration `koanf:"ttl"`
// MaxSize is the maximum number of entries in the cache. Default: 10000.
MaxSize int `koanf:"max_size"`
}

// AnalyticsPublishersConfig holds configuration for all analytics publishers
type AnalyticsPublishersConfig struct {
Moesif MoesifPublisherConfig `koanf:"moesif"`
Expand Down Expand Up @@ -791,6 +821,13 @@ func defaultConfig() *Config {
Enabled: false,
ArtifactsDir: "/etc/api-platform-gateway/immutable_gateway/artifacts",
},
DevPortalWebhook: DevPortalWebhookConfig{
Enabled: false,
Idempotency: DevPortalIdempotencyConfig{
TTL: 10 * time.Minute,
MaxSize: 10_000,
},
},
}
}

Expand Down Expand Up @@ -1056,6 +1093,11 @@ func (c *Config) Validate() error {
return err
}

// Validate devportal webhook configuration when enabled
if err := c.validateDevPortalWebhookConfig(); err != nil {
return err
}

return nil
}

Expand Down Expand Up @@ -1582,3 +1624,23 @@ func (c *Config) validateHTTPListenerConfig() error {

return nil
}

func (c *Config) validateDevPortalWebhookConfig() error {
wh := &c.DevPortalWebhook
if !wh.Enabled {
return nil
}
if strings.TrimSpace(wh.Secret) == "" {
return fmt.Errorf("devportal_webhook.secret is required when devportal_webhook.enabled is true")
}
if strings.TrimSpace(wh.PrivateKeyPath) == "" {
return fmt.Errorf("devportal_webhook.private_key_path is required when devportal_webhook.enabled is true")
}
if wh.Idempotency.TTL <= 0 {
return fmt.Errorf("devportal_webhook.idempotency.ttl must be positive")
}
if wh.Idempotency.MaxSize <= 0 {
return fmt.Errorf("devportal_webhook.idempotency.max_size must be positive")
}
return nil
}
120 changes: 120 additions & 0 deletions gateway/gateway-controller/pkg/devportalwebhook/crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package devportalwebhook

import (
"crypto/aes"
"crypto/cipher"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"os"
)

// LoadRSAPrivateKey reads and parses a PEM-encoded RSA private key from the given path.
// Call this once at startup and pass the result to NewHandler.
func LoadRSAPrivateKey(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading RSA private key: %w", err)
}

block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block found in %s", path)
}

// Support both PKCS#1 and PKCS#8 encoded keys.
switch block.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing PKCS#1 RSA private key: %w", err)
}
return key, nil
case "PRIVATE KEY":
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing PKCS#8 private key: %w", err)
}
rsaKey, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("PKCS#8 key in %s is not an RSA key", path)
}
return rsaKey, nil
default:
return nil, fmt.Errorf("unsupported PEM block type %q in %s", block.Type, path)
}
}

// DecryptAPIKey decrypts the hybrid-encrypted API key from the devportal webhook payload.
//
// Protocol:
// 1. base64-decode wrappedKey → RSA-OAEP (SHA-256) decrypt with private key → 32-byte AES key
// 2. base64-decode iv, tag, ciphertext
// 3. AES-256-GCM open with ciphertext||tag (Go's AEAD convention)
// 4. Return the plaintext API key string
func DecryptAPIKey(priv *rsa.PrivateKey, enc *EncryptedKey) (string, error) {
wrappedKey, err := base64.StdEncoding.DecodeString(enc.WrappedKey)
if err != nil {
return "", fmt.Errorf("base64-decoding wrappedKey: %w", err)
}

aesKey, err := rsa.DecryptOAEP(sha256.New(), nil, priv, wrappedKey, nil)
if err != nil {
return "", fmt.Errorf("RSA-OAEP decrypting wrapped key: %w", err)
}

iv, err := base64.StdEncoding.DecodeString(enc.IV)
if err != nil {
return "", fmt.Errorf("base64-decoding iv: %w", err)
}

tag, err := base64.StdEncoding.DecodeString(enc.Tag)
if err != nil {
return "", fmt.Errorf("base64-decoding tag: %w", err)
}

ciphertext, err := base64.StdEncoding.DecodeString(enc.Ciphertext)
if err != nil {
return "", fmt.Errorf("base64-decoding ciphertext: %w", err)
}

block, err := aes.NewCipher(aesKey)
if err != nil {
return "", fmt.Errorf("creating AES cipher: %w", err)
}

gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("creating GCM: %w", err)
}

// Go's gcm.Open expects the authentication tag appended to the ciphertext.
combined := append(ciphertext, tag...)
plaintext, err := gcm.Open(nil, iv, combined, nil)
if err != nil {
return "", fmt.Errorf("AES-256-GCM decryption failed: %w", err)
}

return string(plaintext), nil
}
Loading
Loading