diff --git a/README.md b/README.md index b6219c4..5369fd8 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,8 @@ Envoy WASM plugin that intercepts HTTP requests, reads a configurable header (e.g. `x-example` set by someone after a successful authentication), -encrypts its value with **AES-256-CBC** (or sets a fixed value) and stores the -result as a `Set-Cookie` header in the response so downstream services -(e.g. Akamai) can read it. +encrypts its value with **AES-256-GCM** (AEAD) and stores the result as a `Set-Cookie` header +in the response so downstream services (e.g. Akamai) can read it. Built on [`proxy-wasm-go-sdk`](https://github.com/proxy-wasm/proxy-wasm-go-sdk). Module: `github.com/freepik-company/ceep`. @@ -18,72 +17,43 @@ Module: `github.com/freepik-company/ceep`. 3. If the header is absent or empty, the plugin does nothing and continues. 4. It checks the request `:authority` header against the configured `cookie_domains` list. If the authority is not in the list, the plugin skips and continues. -5. Depending on the configured mode it either: - - Encrypts the header value with AES-256-CBC (random or fixed IV), or - - Uses a static cookie value loaded from the `CEEP_STATIC_COOKIE_VALUE` environment variable. +5. It encrypts the header value with AES-256-GCM (random 12-byte nonce per request). 6. It injects a `Set-Cookie` header using the request authority as the cookie domain. --- ## Cookie value modes -The plugin supports three modes for generating the cookie value: +### Encrypted header (default) -### Random IV (default) - -A random 16-byte IV is generated per request and prepended to the ciphertext. -Only `CEEP_ENCRYPTION_KEY` is required. - -``` -Set-Cookie: =; Path=/; Domain=; SameSite=Lax; -``` - -### Fixed IV - -A shared 16-byte IV is loaded from `CEEP_ENCRYPTION_IV` at startup. -The IV is **not** included in the output. Set `iv_mode: "fixed"` in `pluginConfig`. - -``` -Set-Cookie: =; Path=/; Domain=; SameSite=Lax; -``` - -### Static cookie value - -No encryption is performed. The cookie value is taken directly from the -`CEEP_STATIC_COOKIE_VALUE` environment variable. Set `set_static_cookie_value: true` -in `pluginConfig`. +Requires `CEEP_ENCRYPTION_KEY` (32 raw bytes). Each request uses a **new random 12-byte nonce** prepended to the GCM ciphertext and tag. ``` -Set-Cookie: =; Path=/; Domain=; SameSite=Lax; +Set-Cookie: =; Path=/; Domain=; SameSite=Lax; HttpOnly; Secure ``` -### Encryption details (Random IV / Fixed IV modes) +| Field | Details | +| --------- | ----------------------------------------------------------------------- | +| Algorithm | AES-256-GCM (AEAD) | +| Key size | 32 bytes (raw, not base64) | +| Nonce | Random 12 bytes, prepended to the sealed payload | +| Tag | 16 bytes, appended by GCM and verified on decrypt | +| Encoding | Standard base64 (RFC 4648) | -| Field | Details | -| --------- | ----------------------------------------------------------------- | -| Algorithm | AES-256-CBC | -| Key size | 32 bytes (raw, not base64) | -| IV | Random 16 bytes prepended to ciphertext, or fixed 16 bytes shared | -| Padding | PKCS#7 | -| Encoding | Standard base64 (RFC 4648) | +On Akamai (or any consumer), read the nonce from the first 12 bytes after base64 decode; do **not** use a fixed nonce/IV for all cookies — that would break semantic security. ### Decryption pseudocode (e.g. Akamai EdgeWorkers) -Random IV: - ```javascript -const raw = atob(cookieValue); -const iv = raw.slice(0, 16); -const payload = raw.slice(16); -const value = aes256cbcDecrypt(key, iv, payload); +const raw = atob(cookieValue); // base64-decode +const nonce = raw.slice(0, 12); +const sealed = raw.slice(12); // ciphertext || 16-byte tag +const value = aes256gcmOpen(key, nonce, sealed); // authentication error if tampered ``` -Fixed IV: +### Static cookie value -```javascript -const payload = atob(cookieValue); -const value = aes256cbcDecrypt(key, sharedIV, payload); -``` +With `set_static_cookie_value: true` and `CEEP_STATIC_COOKIE_VALUE`, the cookie value is set from env with no encryption (see samples in `docs/samples/`). --- diff --git a/docs/samples/istio-wasmplugin.yaml b/docs/samples/istio-wasmplugin.yaml index 4c8ed38..245e6d6 100644 --- a/docs/samples/istio-wasmplugin.yaml +++ b/docs/samples/istio-wasmplugin.yaml @@ -2,7 +2,7 @@ # # The plugin intercepts HTTP requests handled by oauth2-proxy, reads the header # specified in header_to_encrypt_name (set by oauth2-proxy after successful auth), -# encrypts it with AES-256-CBC and injects the result as a Set-Cookie header in +# encrypts it with AES-256-GCM and injects the result as a Set-Cookie header in # the response so Akamai can read it on subsequent requests. # # Prerequisites: @@ -17,26 +17,15 @@ # - "something.example.com" matches exactly → Cookie Domain: something.example.com # # Akamai decryption: -# - Algorithm : AES-256-CBC +# - Algorithm : AES-256-GCM (random 12-byte nonce per cookie, prepended to the value) # - Key : same raw 32-byte key -# - IV : random (prepended to cookie) or fixed (shared, set via CEEP_ENCRYPTION_IV) -# - Ciphertext : base64-decoded cookie value (without IV prefix when using fixed IV) -# - Padding : PKCS#7 -# -# IV modes (pluginConfig.iv_mode): -# - "random" (default): a random 16-byte IV is generated per request and -# prepended to the ciphertext: base64(IV || ciphertext). -# - "fixed": CEEP_ENCRYPTION_IV env var must be set (exactly 16 bytes). The IV is -# NOT included in the output: base64(ciphertext). Both sides share the IV. -# -# Static cookie mode (pluginConfig.set_static_cookie_value): -# - When true, the cookie value is taken directly from the CEEP_STATIC_COOKIE_VALUE -# environment variable without any encryption. No CEEP_ENCRYPTION_KEY or CEEP_ENCRYPTION_IV -# is required. +# - Nonce : first 12 bytes of the base64-decoded cookie value +# - Sealed : remaining bytes (ciphertext || 16-byte authentication tag) --- -# Option A: Random IV (default). Only CEEP_ENCRYPTION_KEY is required. -# iv_mode is "random" by default, so it can be omitted. +# Option A (production): key injected from a K8s Secret via secretKeyRef. +# Use External Secrets Operator (or Sealed Secrets) to sync the key from your +# secrets store into the Secret below. apiVersion: extensions.istio.io/v1alpha1 kind: WasmPlugin metadata: @@ -68,42 +57,7 @@ spec: header_to_encrypt_name: "x-auth-request-email" --- -# Option B: Fixed IV. Set iv_mode to "fixed" and provide ENCRYPTION_IV. -# The IV is NOT included in the cookie output — both sides must share it. -apiVersion: extensions.istio.io/v1alpha1 -kind: WasmPlugin -metadata: - name: ceep - namespace: gateway -spec: - selector: - matchLabels: - app.kubernetes.io/instance: gateway - - url: oci://ghcr.io/freepik-company/ceep:latest - imagePullPolicy: IfNotPresent - - type: HTTP - phase: STATS - - vmConfig: - env: - - name: CEEP_ENCRYPTION_KEY - valueFrom: HOST - - name: CEEP_ENCRYPTION_IV - valueFrom: HOST - - pluginConfig: - iv_mode: "fixed" - cookie_name: "_extauth_signed_email" - cookie_domains: - - domain: "*.example.com" - wildcard_matches_base: true - - domain: "*.example.com" - - domain: "example.com" - header_to_encrypt_name: "x-auth-request-email" ---- -# Option C: Fixed cookie value. Set set_static_cookie_value to "true" and provide CEEP_STATIC_COOKIE_VALUE. +# Option B: Fixed cookie value. Set set_static_cookie_value to "true" and provide CEEP_STATIC_COOKIE_VALUE. apiVersion: extensions.istio.io/v1alpha1 kind: WasmPlugin metadata: diff --git a/main.go b/main.go index 3526698..6186d81 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,14 @@ // This WASM plugin for Envoy is designed to intercept oauth2-proxy responses, -// read X-Auth-Request-Email header and inject it encrypted (AES-256-CBC) as the +// read X-Auth-Request-Email header and inject it encrypted (AES-256-GCM) as the // _extauth_signed_email cookie so Akamai can use it to bypass rate-limit rules // for authenticated users. // -// The AES-256 encryption key is loaded from the ENCRYPTION_KEY environment variable, +// The AES-256 encryption key is loaded from the CEEP_ENCRYPTION_KEY environment variable, // injected at runtime by Istio from a Kubernetes Secret (WasmPlugin.vmConfig.env), // so the key never appears in any YAML file committed to the repository. // -// Cookie value modes: -// - Random IV (default): base64(IV[16 bytes] || AES-256-CBC(email)) -// - Fixed IV (ENCRYPTION_IV set): base64(AES-256-CBC(email)) -// - Static value (set_static_cookie_value: true): CEEP_STATIC_COOKIE_VALUE env var used as-is +// Encrypted cookie format: base64(nonce[12] || AES-256-GCM ciphertext || tag[16]) +// Akamai decrypts using the nonce from the first 12 bytes; GCM Open verifies the tag. // // Ref: https://github.com/tetratelabs/proxy-wasm-go-sdk/blob/main/examples/http_headers/ @@ -33,11 +31,6 @@ const ( // Injected by WasmPlugin.vmConfig.env from a Kubernetes Secret. EnvEncryptionKey = "CEEP_ENCRYPTION_KEY" - // EnvEncryptionIV is the optional environment variable holding a fixed 16-byte IV. - // When set, the IV is NOT prepended to the ciphertext (both sides already know it). - // When unset, a random IV is generated per request and prepended to the output. - EnvEncryptionIV = "CEEP_ENCRYPTION_IV" - // EnvStaticCookieValue is the environment variable holding a fixed cookie value. // When set_static_cookie_value is true, this value is used directly as the cookie // value without any encryption. @@ -73,15 +66,9 @@ type pluginContext struct { // Following fields are configured during OnPluginStart. // encryptionKey is the AES-256 key (exactly 32 bytes). - // Loaded from the ENCRYPTION_KEY environment variable. + // Loaded from CEEP_ENCRYPTION_KEY when not using static cookie mode. encryptionKey []byte - // encryptionIV is the fixed 16-byte IV (only used when ivMode is "fixed"). - encryptionIV []byte - - // ivMode is "random" or "fixed". - ivMode string - // setStaticCookieValue indicates whether to use a fixed cookie value from env // instead of encrypting the header value dynamically. setStaticCookieValue bool @@ -167,27 +154,7 @@ func (p *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPlugi p.encryptionKey = []byte(encryptionKey) - p.ivMode = "random" - if v := gjson.Get(string(data), "iv_mode").Str; v != "" { - p.ivMode = v - } - - if p.ivMode == "fixed" { - encryptionIV := os.Getenv(EnvEncryptionIV) - if encryptionIV == "" { - proxywasm.LogCriticalf("%s environment variable is required when iv_mode is \"fixed\"", EnvEncryptionIV) - return types.OnPluginStartStatusFailed - } - - if len(encryptionIV) != 16 { - proxywasm.LogCriticalf("%s must be exactly 16 bytes, got %d", EnvEncryptionIV, len(encryptionIV)) - return types.OnPluginStartStatusFailed - } - - p.encryptionIV = []byte(encryptionIV) - } - - proxywasm.LogDebugf("plugin configured: cookie_name=%s cookie_domains=%v header_to_encrypt_name=%s iv_mode=%s", p.cookieName, p.cookieDomains, p.headerToEncryptName, p.ivMode) + proxywasm.LogDebugf("plugin configured: cookie_name=%s cookie_domains=%v header_to_encrypt_name=%s", p.cookieName, p.cookieDomains, p.headerToEncryptName) return types.OnPluginStartStatusOK } @@ -201,12 +168,6 @@ type httpContext struct { // encryptionKey is the AES-256 key used to encrypt the email value. encryptionKey []byte - // encryptionIV is the fixed 16-byte IV (only used when ivMode is "fixed"). - encryptionIV []byte - - // ivMode is "random" or "fixed". - ivMode string - // setStaticCookieValue indicates whether to use a fixed cookie value from env. setStaticCookieValue bool @@ -234,8 +195,6 @@ func (p *pluginContext) NewHttpContext(contextID uint32) types.HttpContext { return &httpContext{ contextID: contextID, encryptionKey: p.encryptionKey, - encryptionIV: p.encryptionIV, - ivMode: p.ivMode, setStaticCookieValue: p.setStaticCookieValue, staticCookieValue: p.staticCookieValue, cookieName: p.cookieName, @@ -281,24 +240,7 @@ func (h *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) typ return types.ActionContinue } - var iv []byte - var prependIV bool - - switch h.ivMode { - case "fixed": - iv = h.encryptionIV - prependIV = false - default: - var err error - iv, err = generateRandomIV() - if err != nil { - proxywasm.LogCriticalf("failed to generate IV: %v", err) - return types.ActionContinue - } - prependIV = true - } - - encryptedHeaderValue, err := encryptAES256CBC(h.encryptionKey, iv, headerValue, prependIV) + encryptedHeaderValue, err := encryptAES256GCM(h.encryptionKey, headerValue) if err != nil { proxywasm.LogCriticalf("failed to encrypt email: %v", err) return types.ActionContinue @@ -320,7 +262,7 @@ func (h *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) ty return types.ActionContinue } - cookieValue := fmt.Sprintf("%s=%s; Path=/; Domain=%s; SameSite=Lax;", h.cookieName, h.encryptedHeaderValue, h.authority) + cookieValue := fmt.Sprintf("%s=%s; Path=/; Domain=%s; SameSite=Lax; HttpOnly; Secure", h.cookieName, h.encryptedHeaderValue, h.authority) if err := proxywasm.AddHttpResponseHeader("set-cookie", cookieValue); err != nil { proxywasm.LogCriticalf("failed to add Set-Cookie header: %v", err) diff --git a/utils.go b/utils.go index 99dd09f..d12ce60 100644 --- a/utils.go +++ b/utils.go @@ -10,55 +10,37 @@ import ( "strings" ) -// generateRandomIV returns a cryptographically random 16-byte IV. -func generateRandomIV() ([]byte, error) { - iv := make([]byte, aes.BlockSize) - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return nil, fmt.Errorf("failed to generate random IV: %w", err) - } - return iv, nil -} - -// encryptAES256CBC encrypts plaintext using AES-256-CBC with the given IV. -// The caller is responsible for providing the IV (random or fixed). -// When prependIV is true the IV is prepended to the ciphertext before base64 encoding. -func encryptAES256CBC(key, iv []byte, plaintext string, prependIV bool) (string, error) { +// encryptAES256GCM encrypts plaintext using AES-256-GCM with a random 12-byte nonce. +// +// Output format (Akamai-compatible): base64(nonce[12] || ciphertext || tag[16]) +// The 16-byte authentication tag is cryptographically bound to nonce and ciphertext. +// +// Akamai side to decrypt: +// - Algorithm : AES-256-GCM +// - Key : the same 32-byte raw key +// - Nonce : first 12 bytes of the base64-decoded cookie value +// - Sealed : remaining bytes (ciphertext concatenated with 16-byte tag, as from Seal/Open) +func encryptAES256GCM(key []byte, plaintext string) (string, error) { block, err := aes.NewCipher(key) if err != nil { return "", fmt.Errorf("failed to create AES cipher: %w", err) } - padded, err := pkcs7Pad([]byte(plaintext), aes.BlockSize) + gcm, err := cipher.NewGCM(block) if err != nil { - return "", fmt.Errorf("failed to pad plaintext: %w", err) - } - - ciphertext := make([]byte, len(padded)) - mode := cipher.NewCBCEncrypter(block, iv) - mode.CryptBlocks(ciphertext, padded) - - if prependIV { - return base64.StdEncoding.EncodeToString(append(iv, ciphertext...)), nil + return "", fmt.Errorf("failed to create GCM: %w", err) } - return base64.StdEncoding.EncodeToString(ciphertext), nil -} - -// pkcs7Pad applies PKCS#7 padding so the plaintext length is a multiple of blockSize. -func pkcs7Pad(data []byte, blockSize int) ([]byte, error) { - if blockSize <= 0 || blockSize > 255 { - return nil, fmt.Errorf("invalid block size %d", blockSize) + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate random nonce: %w", err) } - padLen := blockSize - len(data)%blockSize - padded := make([]byte, len(data)+padLen) - copy(padded, data) - - for i := len(data); i < len(padded); i++ { - padded[i] = byte(padLen) - } + sealed := gcm.Seal(nil, nonce, []byte(plaintext), nil) + buf := append(nonce, sealed...) - return padded, nil + // Standard base64 is valid in cookie values (RFC 6265) and is what Akamai expects. + return base64.StdEncoding.EncodeToString(buf), nil } // domainEntry represents a single domain configuration entry.