How to Use Kong JWE Decrypt Plugin

Intro

Kong Enterprise 3.1.0.0 release includes a few new plugins. Among all, JWE decrypt plugin interests me the most. If you’ve read my OIDC and JWT posts before, you will know that Kong supports JWT very well but those are limited to JWS tokens.

Here is an example of JWS token

eyJhbGciOiJFUzI1NiIsImtpZCI6Imh1SEE3RDVaTUNKTWhLaVJIZVgwaGZSWG9fX1VBbEpCZ0FkTjhxb0MwMXcifQ.eyJpc3MiOiAiZm9tbSIsICJhdWQiOiAiand0LWRlbW8iLCAic3ViIjogImRlbW8tdXNlciIsICJpYXQiOiAxNjcyNDAzMzc4LCAiZXhwIjogMTY3MjQwMzY3OH0.bxkLGEjN4pXQQ6eymBO_DYl24NGu07FFR1ZXgmdFYHPGsNX10r6iyqDEtCHeXWs7Hsn-QIasV_i4Lw2nCHmlAA
  • JOSE header
    Tells the recipient what algorithm is used to secure this JWS. If we decode it with base64 we should get.

    1
    2
    3
    4
    {
    "alg": "ES256",
    "kid": "huHA7D5ZMCJMhKiRHeX0hfRXo__UAlJBgAdN8qoC01w"
    }
  • Payload
    We can also base64 decode it and we should get below payload.

    1
    2
    3
    4
    5
    6
    7
    {
    "iss": "fomm",
    "aud": "jwt-demo",
    "sub": "demo-user",
    "iat": 1672403378,
    "exp": 1672403678
    }
  • Signature
    Users need to compute signature of protected header and payload using algorithm defined in JOSE header and make sure signature matches.

As you can see, base64 encoded payload is not encrypted at all. Anyone has this JWT can easily decode the payload. These tokens are good if you only care about authenticity of the tokens. If you need to hide sensitive data in payload, you must use JWE tokens.

In today post, I would like to explain what JWE tokens are, how to generate JWE tokens and how you can use JWE decrypt plugin with other authentication plugins.

Let’s get started.

Prerequisites:

  • Kong Enterprise version 3.1.x
  • Python

What is JWE token

JWE stands for JSON Web Encryption represents encrypted content using JSON-based data structures.

Let me also give you an example:

eyJhbGciOiJSU0EtT0FFUCIsImN0eSI6IkpXVCIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiJnMHRpU09YVzFtNlUyT3VGaTU3TjRTN0ltX1ZFdFczaWx6US03dFk3SUowIiwidHlwIjoiSldFIn0.ydARESwF0CnK6D6v0Sd9RAwRZnUTGa8rBWM33f4hIAkCAwjZYVNmc3F9D_teczmjBp0w-bFMs_p0nq7TtDjAyac591R9TN_knMyiQr_GvsngrEQC6oglDGPG2jNII-42MpFBU1t-HlRpfWLWGhRRy6JgUf_Q3b57KEwVaHV3H4AHrC1v9HoVjkmSpG5Sy8jm7KHTy8qkMXNAYOFCtGV05lCkPPblhaiu8720WPiyAewxFHa4EJLMwk59o89qpc-4CJfhyODtOelg7CDfrN3hfd7003ThMG2-wSwzmBLq9WHoUXGhrwk20fljAX5c3uhD7zwcdqsaH-bVnsnlU_ohtA.oIzmN8_p4WaSHf21.EJDAqKX3X8zkl_UWO_KXXfDxPBW5Pf0LhDsXVAuNgchP7qSNbEdFVosDB7tgbDyoRmQ_to1LZTNda9VcKfPspskfqT7AjEV2zHue0jy6rRO3qgSQ9lNICjxiUVWqI-kCspBM7JaT8KEBfmLW3i9YxIaDrO1nC_ZgKzw89tzZq0EL9Z1oZ6vMECPlbkrrYqXh1y-xXFU3iTAIv-MQq4quCGTJf3PCykOPHL05-TvBRGBB5GAf1c0fmrSWJs475ZP1mYhfEZhOw5yiF67H6JrqgVb9ywwYud1JV8nLVr_upzGMhxFYzO2HqmF2zktL4uUrnKbEhJbW5izF4LbUECWGL0NwVqhfoqKuHf7vr2be1VuaEBB9HM-RZd2Wy89iOZQSUXxT48Wz8S4hy75Lbg.ESXyUZ4ZDsOIIgX8lbOyfw

The JWE compact serialization form has five main components:

  • JOSE header

    The JOSE header tells recipient the “alg” (Algorithm) used to encrypt the CEK (Content Encryption Key), “cty” (Content Type) of the payload, “enc” (Encryption Algorithm) used to encrypt payload to produce the ciphertext and the Authentication Tag and “typ” (Type) of this token.

    1
    2
    3
    4
    5
    6
    7
    {
    "alg": "RSA-OAEP",
    "cty": "JWT",
    "enc": "A256GCM",
    "kid": "g0tiSOXW1m6U2OuFi57N4S7Im_VEtW3ilzQ-7tY7IJ0",
    "typ": "JWE"
    }
  • JWE Encrypted Key:
    This is the encrypted (CEK) using the RSAES-OAEP algorithm. Recipient must have the private key to get the CEK (A symmetric key) for decrypting the payload.

  • JWE initialization vector, JWE Ciphertext, JWE Authentication Tag
    To encrypt payload, AES-GCM takes in an AES key, an initialization vector (IV), a plaintext payload, and optional additional authenticated data (AAD). It generates two outputs, a ciphertext and authentication tag. Ciphertext is not base64 encoded on JWE token so we can’t get the payload. To get the payload, we need to use the private key to decrypt the AES key first, then we will be able to decrypt the ciphertext. Authentication tag is used to prove the authenticity and integrity of the payload.

These is a lot of info to digest. Let’s create some JWE tokens to help us understand.

Generate Token

I will use python jwcrypto library to generate JWE token. If you are not comfortable running python code, you can also find a list of libraries here. Just choose the one for the programming language you are familiar with.

Generate RSA key pairs

In order to decrypt the token later, we need to prepare our own RSA key pair. On Mac and Linux, you should have openssl pre-install so you can run below commands.

1
2
3
4
5
6
7
8
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-outform pem -out jwe-rsa-private.pem 2>/dev/null

openssl pkey -pubout \
-in jwe-rsa-private.pem \
-out jwe-rsa-public.pem

Generate EC key pairs

We also need another pair of keys for creating a JWS token that will be used as payload on our JWE token.

1
2
3
4
5
6
7
8
openssl genpkey -algorithm EC \
-pkeyopt ec_paramgen_curve:P-256 \
-pkeyopt ec_param_enc:named_curve \
-out jwt-ecc-private.pem 2>/dev/null

openssl pkey -pubout \
-in jwt-ecc-private.pem \
-out jwt-ecc-public.pem

Generate JWE Token

I am using Python 3.10.6 and pip 22.3.1. You can find this library official doc here.
Payload can be any plain text but it is very common to use JWS token as payload. With that users can protect the payload as well as validating the payloads, making sure they are signed by the trusted party.

You can install the jwcrypto library by running pip install jwcrypto.

Let’s save below file in the same folder with our key pairs and save it as jwe.py.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from jwcrypto import jwk, jwe, jws
from jwcrypto.common import json_encode
from datetime import datetime
import json

def generate_jws_token(private_key_path):
with open(private_key_path, "rb") as pkeyfile:
jwt_pkey = jwk.JWK.from_pem(pkeyfile.read())

timestamp = int(datetime.now().timestamp())
payload = {
"iss": "fomm",
"aud": "jwt-demo",
"sub": "demo-user",
"iat": timestamp,
"exp": timestamp + 300,
}

jwstoken = jws.JWS(json.dumps(payload))
jwstoken.add_signature(
jwt_pkey, None, json_encode({"alg": "ES256", "kid": jwt_pkey.thumbprint()})
)

return jwstoken.serialize(compact=True)

def generate_jwe_token(jws_token, public_key_path):
with open(public_key_path, "rb") as pubkeyfile:
public_key = jwk.JWK.from_pem(pubkeyfile.read())

protected_header = {
"alg": "RSA-OAEP",
"enc": "A256GCM",
"typ": "JWE",
"cty": "JWT",
"kid": public_key.thumbprint(),
}

jwetoken = jwe.JWE(
jws_token.encode("utf-8"), recipient=public_key, protected=protected_header
)

return jwetoken.serialize(compact=True)

def main():
jws_token = generate_jws_token("jwt-ecc-private.pem")
final_jwe = generate_jwe_token(jws_token, "jwe-rsa-private.pem")
print(final_jwe)

if __name__ == "__main__":
main()

This code is pretty easy to understand and here are the steps:

  1. Create a JWS token (valid for 5 minutes) with EC private key.
  2. Encrypt this JWS token as payload with a randomly generated CEK (by the library).
  3. Create the JWE token with the CEK encrypted with RSA public key.

Now if we run python jwe.py, we should get a similar JWE as in example.

Kong plugins usage

I will show you how to use JWT plugin to validate the JWS AFTER JWE decrypt plugin gets the JWS token from payload. If your OIDC provider issues JWE tokens, you can use JWE decrypt and OIDC/JWT-signer plugin to validate the JWS payload.

Preparation

I assume Kong Enterprise 3.1.x version is running. In the demo I am running 3.1.1.1.

Let me create a service and a route for our testing. I am using httpbin as upstream service.

1
2
3
4
curl -X POST http://localhost:8001/services \
-H "Content-Type: application/json" \
-H "Accept: application/json, */*" \
-d '{"name":"jwe-service","url":"https://httpbin.org/anything"}'
1
2
3
4
curl -X POST http://localhost:8001/services/jwe-service/routes \
-H "Content-Type: application/json" \
-H "Accept: application/json, */*" \
-d '{"name":"jwe-route","paths":["/test"]}'

Decrypt JWE token

Create key-sets and keys

We need to create a key-set object and then upload our RSA key pairs to it.

1
2
3
curl -X POST \
--url http://localhost:8001/key-sets \
--data 'name=jwe-keysets

Create keys. Please note, kid is required and we can get kid from the JWE JOSE header.

1
2
3
4
5
6
7
curl -X POST \
--url http://localhost:8001/keys \
--form 'name=rsa-keys-pem' \
--form [email protected] \
--form [email protected] \
--form 'set.name=jwe-keysets' \
--form 'kid=g0tiSOXW1m6U2OuFi57N4S7Im_VEtW3ilzQ-7tY7IJ0'

Enable JWE decrypt plugin

We can enable the JWE decrypt plugin on the route now.

1
2
3
4
5
curl --request POST \
--url http://localhost:8001/routes/jwe-route/plugins \
--data 'name=jwe-decrypt' \
--data 'config.key_sets=jwe-keysets' \
--data 'config.strict=false'

Let’s send a request to /test without a token.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
❯ curl http://localhost:8000/test
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/7.81.0",
"X-Amzn-Trace-Id": "Root=1-63b0466a-568c7f9e3feaa19c19109541",
"X-Forwarded-Host": "localhost",
"X-Forwarded-Path": "/test",
"X-Forwarded-Prefix": "/test"
},
"json": null,
"method": "GET",
"origin": "192.168.0.1",
"url": "https://localhost/anything"
}

Let’s send request with our JWE token.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
❯ curl http://localhost:8000/test \
--header "authorization: bearer eyJhbGciOiJSU0EtT0FFUCIsImN0eSI6IkpXVCIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiJnMHRpU09YVzFtNlUyT3VGaTU3TjRTN0ltX1ZFdFczaWx6US03dFk3SUowIiwidHlwIjoiSldFIn0.ydARESwF0CnK6D6v0Sd9RAwRZnUTGa8rBWM33f4hIAkCAwjZYVNmc3F9D_teczmjBp0w-bFMs_p0nq7TtDjAyac591R9TN_knMyiQr_GvsngrEQC6oglDGPG2jNII-42MpFBU1t-HlRpfWLWGhRRy6JgUf_Q3b57KEwVaHV3H4AHrC1v9HoVjkmSpG5Sy8jm7KHTy8qkMXNAYOFCtGV05lCkPPblhaiu8720WPiyAewxFHa4EJLMwk59o89qpc-4CJfhyODtOelg7CDfrN3hfd7003ThMG2-wSwzmBLq9WHoUXGhrwk20fljAX5c3uhD7zwcdqsaH-bVnsnlU_ohtA.oIzmN8_p4WaSHf21.EJDAqKX3X8zkl_UWO_KXXfDxPBW5Pf0LhDsXVAuNgchP7qSNbEdFVosDB7tgbDyoRmQ_to1LZTNda9VcKfPspskfqT7AjEV2zHue0jy6rRO3qgSQ9lNICjxiUVWqI-kCspBM7JaT8KEBfmLW3i9YxIaDrO1nC_ZgKzw89tzZq0EL9Z1oZ6vMECPlbkrrYqXh1y-xXFU3iTAIv-MQq4quCGTJf3PCykOPHL05-TvBRGBB5GAf1c0fmrSWJs475ZP1mYhfEZhOw5yiF67H6JrqgVb9ywwYud1JV8nLVr_upzGMhxFYzO2HqmF2zktL4uUrnKbEhJbW5izF4LbUECWGL0NwVqhfoqKuHf7vr2be1VuaEBB9HM-RZd2Wy89iOZQSUXxT48Wz8S4hy75Lbg.ESXyUZ4ZDsOIIgX8lbOyfw"
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Authorization": "Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6Imh1SEE3RDVaTUNKTWhLaVJIZVgwaGZSWG9fX1VBbEpCZ0FkTjhxb0MwMXcifQ.eyJpc3MiOiAiZm9tbSIsICJhdWQiOiAiand0LWRlbW8iLCAic3ViIjogImRlbW8tdXNlciIsICJpYXQiOiAxNjcyNDA1NDUxLCAiZXhwIjogMTY3MjQwNTc1MX0.qIMlPVWLKPgpWy5X3gwyNAkmNTYDzgEAXtYaBPSx7-_Nrdkp4DbQ4MaPWSP181wmUils90S0Zubw6iBwfOUvXw",
"Host": "httpbin.org",
"User-Agent": "curl/7.81.0",
"X-Amzn-Trace-Id": "Root=1-63b046df-6e0c375c007bf42e7e4d9064",
"X-Forwarded-Host": "localhost",
"X-Forwarded-Path": "/test",
"X-Forwarded-Prefix": "/test"
},
"json": null,
"method": "GET",
"origin": "192.168.0.1",
"url": "https://localhost/anything"
}

We can see the upstream (httpbin) received the decoded JWS in the authorization header.

Validate JWT

As you can see from above example, JWE was decrypted but there was no consumer associated with the token. If we want to rate limit consumers, we need to use one of Kong’s authentication plugins. Let me use JWT plugin to demostrate how this works.

If you are not sure how JWT plugin works, please check my previous post.

Enable JWT plugin

I am also enabling JWT plugin on our route.

1
2
3
curl --request POST \
--url http://localhost:8001/routes/jwe-route/plugins \
--data name=jwt

Create consumer

Then we need to create a consumer.

1
2
3
curl --request POST \
--url http://localhost:8001/consumers \
--data username=jwt-user

Create JWT credential

Lastly we need to associate the EC public key to this consumer as JWT credential. (We use the EC key to sign JWS token on our python script).

1
2
3
4
curl -X POST http://localhost:8001/consumers/jwt-user/jwt \
--form [email protected] \
--form algorithm=ES256 \
--form key=fomm

Now if you send the request again you should see headers like X-Consumer-Username and X-Consumer-Id are sent to upstream. This mean Kong also validates the JWS token inside JWE token and found matching consumer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
❯ curl http://localhost:8000/test \
--header "authorization: bearer eyJhbGciOiJSU0EtT0FFUCIsImN0eSI6IkpXVCIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiJnMHRpU09YVzFtNlUyT3VGaTU3TjRTN0ltX1ZFdFczaWx6US03dFk3SUowIiwidHlwIjoiSldFIn0.ydARESwF0CnK6D6v0Sd9RAwRZnUTGa8rBWM33f4hIAkCAwjZYVNmc3F9D_teczmjBp0w-bFMs_p0nq7TtDjAyac591R9TN_knMyiQr_GvsngrEQC6oglDGPG2jNII-42MpFBU1t-HlRpfWLWGhRRy6JgUf_Q3b57KEwVaHV3H4AHrC1v9HoVjkmSpG5Sy8jm7KHTy8qkMXNAYOFCtGV05lCkPPblhaiu8720WPiyAewxFHa4EJLMwk59o89qpc-4CJfhyODtOelg7CDfrN3hfd7003ThMG2-wSwzmBLq9WHoUXGhrwk20fljAX5c3uhD7zwcdqsaH-bVnsnlU_ohtA.oIzmN8_p4WaSHf21.EJDAqKX3X8zkl_UWO_KXXfDxPBW5Pf0LhDsXVAuNgchP7qSNbEdFVosDB7tgbDyoRmQ_to1LZTNda9VcKfPspskfqT7AjEV2zHue0jy6rRO3qgSQ9lNICjxiUVWqI-kCspBM7JaT8KEBfmLW3i9YxIaDrO1nC_ZgKzw89tzZq0EL9Z1oZ6vMECPlbkrrYqXh1y-xXFU3iTAIv-MQq4quCGTJf3PCykOPHL05-TvBRGBB5GAf1c0fmrSWJs475ZP1mYhfEZhOw5yiF67H6JrqgVb9ywwYud1JV8nLVr_upzGMhxFYzO2HqmF2zktL4uUrnKbEhJbW5izF4LbUECWGL0NwVqhfoqKuHf7vr2be1VuaEBB9HM-RZd2Wy89iOZQSUXxT48Wz8S4hy75Lbg.ESXyUZ4ZDsOIIgX8lbOyfw"
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Authorization": "Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6Imh1SEE3RDVaTUNKTWhLaVJIZVgwaGZSWG9fX1VBbEpCZ0FkTjhxb0MwMXcifQ.eyJpc3MiOiAiZm9tbSIsICJhdWQiOiAiand0LWRlbW8iLCAic3ViIjogImRlbW8tdXNlciIsICJpYXQiOiAxNjcyNDA1NDUxLCAiZXhwIjogMTY3MjQwNTc1MX0.qIMlPVWLKPgpWy5X3gwyNAkmNTYDzgEAXtYaBPSx7-_Nrdkp4DbQ4MaPWSP181wmUils90S0Zubw6iBwfOUvXw",
"Host": "httpbin.org",
"User-Agent": "curl/7.81.0",
"X-Amzn-Trace-Id": "Root=1-63b04841-64b61466203e34db4d20d74c",
"X-Consumer-Id": "4b1eba0a-85d4-4c0e-abf0-9d5f755f3ee5",
"X-Consumer-Username": "jwt-user",
"X-Credential-Identifier": "fomm",
"X-Forwarded-Host": "localhost",
"X-Forwarded-Path": "/test",
"X-Forwarded-Prefix": "/test"
},
"json": null,
"method": "GET",
"origin": "192.168.0.1",
"url": "https://localhost/anything"
}

That’s all I want to share with you. It is the first day of 2023, I hope this post is a good start of the new year.

See you on the next one.