Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
54 changes: 16 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -18,9 +17,7 @@ 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.

---
Expand All @@ -35,47 +32,28 @@ A random 16-byte IV is generated per request and prepended to the ciphertext.
Only `CEEP_ENCRYPTION_KEY` is required.

```
Set-Cookie: <cookie_name>=<base64(IV[16] || ciphertext)>; Path=/; Domain=<domain>; SameSite=Lax;
Set-Cookie: <cookie_name>=<base64(nonce[12] || AES-256-GCM(header_value))>; Path=/; Domain=<domain>; SameSite=Lax; HttpOnly; Secure
```

### Fixed IV
| Field | Details |
| --------- | ----------------------------------------------------------------------- |
| Algorithm | AES-256-GCM (AEAD) |
| Key size | 32 bytes (raw, not base64) |
| Nonce | Random 12 bytes, prepended to ciphertext (same layout as the former IV) |
| Tag | 16 bytes, appended by GCM and verified on decrypt (forgery fails Open) |
| Encoding | Standard base64 (RFC 4648) |

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: <cookie_name>=<base64(ciphertext)>; Path=/; Domain=<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`.

```
Set-Cookie: <cookie_name>=<CEEP_STATIC_COOKIE_VALUE>; Path=/; Domain=<domain>; SameSite=Lax;
```

### Encryption details (Random IV / Fixed IV modes)

| 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, configure decryption with a **random nonce per cookie** (do not use `iv_mode: "fixed"`; it breaks 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); // must verify tag (authentication error on tampering)
```

Fixed IV:
Expand Down
25 changes: 7 additions & 18 deletions docs/samples/istio-wasmplugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -17,26 +17,15 @@
# - "something.example.com" matches exactly → Cookie Domain: something.example.com
#
# Akamai decryption:
# - Algorithm : AES-256-CBC
# - Algorithm : AES-256-GCM (use random nonce per cookie; do not use iv_mode: fixed)
# - 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:
Expand Down
29 changes: 5 additions & 24 deletions main.go
Original file line number Diff line number Diff line change
@@ -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,
// 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/

Expand Down Expand Up @@ -281,24 +279,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
Expand All @@ -320,7 +301,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)
Expand Down
58 changes: 20 additions & 38 deletions utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down