Skip to content
Open
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
30 changes: 29 additions & 1 deletion apisix/plugins/jwe-decrypt.lua
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ local function load_jwe_token(jwe_token)
end


-- the plugin only implements direct encryption with A256GCM; reject a token
-- that asks for anything else instead of failing later with a decrypt error
local function unsupported_header(header_obj)
if header_obj.alg ~= nil and header_obj.alg ~= "dir" then
return true
end

return header_obj.enc ~= nil and header_obj.enc ~= "A256GCM"
end


local function jwe_decrypt_with_obj(o, consumer)
local secret = get_secret(consumer.auth_conf)
if not secret then
Expand All @@ -160,7 +171,20 @@ local function jwe_decrypt_with_obj(o, consumer)
return nil, err
end

return aes_default:decrypt(ciphertext, tag)
-- RFC 7516 authenticates the encoded protected header as the AES-GCM
-- additional authenticated data, which is what JWE libraries produce
local decrypted, decrypt_err = aes_default:decrypt(ciphertext, tag, o.header)
if decrypted then
return decrypted
end

-- tokens built the way APISIX used to build them carry no AAD
local plaintext, legacy_err = aes_default:decrypt(ciphertext, tag)
if not plaintext then
return nil, decrypt_err or legacy_err
end

return plaintext
end


Expand Down Expand Up @@ -212,6 +236,10 @@ function _M.rewrite(conf, ctx)
return 400, { message = "missing kid in JWE token" }
end

if unsupported_header(jwe_obj.header_obj) then
return 400, { message = "unsupported alg or enc in JWE token" }
end

local consumer = get_consumer(jwe_obj.header_obj.kid)
if not consumer then
return 400, { message = "invalid kid in JWE token" }
Expand Down
20 changes: 11 additions & 9 deletions docs/en/latest/plugins/jwe-decrypt.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ import TabItem from '@theme/TabItem';

The `jwe-decrypt` Plugin reads a five-part compact token from a request header, selects a [Consumer](../terminology/consumer.md) by the token's `kid`, decrypts the ciphertext with AES-256-GCM, and writes the plaintext to a configured header before proxying the request. You can enable the Plugin on APISIX [Routes](../terminology/route.md) or [Services](../terminology/service.md).

The token resembles [JWE Compact Serialization](https://datatracker.ietf.org/doc/html/rfc7516#section-3.1), but the current Plugin uses a Plugin-specific format. Configure a 32-byte decryption secret on the Consumer.
The token uses [JWE Compact Serialization](https://datatracker.ietf.org/doc/html/rfc7516#section-3.1) with the `dir` key management algorithm and the `A256GCM` content encryption algorithm, so a token produced by a standard JWE library is accepted. Configure a 32-byte decryption secret on the Consumer.

:::warning

The current implementation reads `kid` from the decoded header but does not validate the `alg` or `enc` fields and does not use the protected-header segment as AES-GCM additional authenticated data (AAD). Standard RFC 7516 JWE libraries are therefore not directly interoperable. Generate tokens with the exact format described below, use a fixed trusted token generator, and do not treat header fields as authenticated.
For backward compatibility, the Plugin also accepts a token whose ciphertext was encrypted without the protected header as AES-GCM additional authenticated data (AAD), which is how APISIX itself used to generate them. The header of such a token, including its `kid`, is not authenticated. Use a trusted token generator, and prefer a JWE library that follows RFC 7516 so that the header is covered by the AAD.

:::

Expand All @@ -69,7 +69,7 @@ The decrypted plaintext is forwarded in a request header. For sensitive plaintex
| -------------- | ------- | -------- | ------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| header | string | True | Authorization | | The header to get the token from. |
| forward_header | string | True | Authorization | | Name of the header that passes the plaintext to the Upstream. |
| strict | boolean | False | true | | If true, return a 403 error when the encrypted plugin token is missing. If false, continue when the token is not found. |
| strict | boolean | False | true | | If true, return a 403 error when the JWE token is missing. If false, continue when the token is not found. |

## Examples

Expand All @@ -87,7 +87,7 @@ admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"/

### Create a Consumer with the Decryption Key

The following example demonstrates how to create a Consumer with the decryption key and generate an encrypted plugin token for it.
The following example demonstrates how to create a Consumer with the decryption key and generate a JWE token for it.

Create a Consumer with `jwe-decrypt` and configure the decryption key:

Expand Down Expand Up @@ -172,23 +172,25 @@ kubectl apply -f jwe-consumer-ic.yaml
</TabItem>
</Tabs>

To generate a token for the Consumer, encrypt the payload offline with AES-256-GCM without protected-header AAD, using the Consumer secret as the key. Standard RFC 7516 libraries normally authenticate the protected header as AAD and are not directly interoperable with this Plugin. Use the following exact token structure:
To generate a JWE token for the Consumer, use any JWE library that supports direct encryption with `A256GCM`, with the Consumer secret as the key. The token structure is:

```text
base64url(header).<empty>.base64url(iv).base64url(ciphertext).base64url(tag)
```

where the header is `{"alg":"dir","enc":"A256GCM","kid":"<consumer-key>"}`. The fields describe the intended algorithm and identify the Consumer, but the current Plugin does not authenticate or validate them. The IV must be unique and randomly generated for every token; never reuse an IV with the same key.
where the header is `{"alg":"dir","enc":"A256GCM","kid":"<consumer-key>"}`; `alg` and `enc` are rejected if they are set to anything else. The IV must be unique and randomly generated for every token; never reuse an IV with the same key.

As [RFC 7516](https://datatracker.ietf.org/doc/html/rfc7516#section-5.1) requires, a JWE library authenticates the encoded protected header as the AES-GCM additional authenticated data (AAD), which makes the `kid` tamper-proof. Tokens encrypted without AAD, such as the ones APISIX itself used to generate, are still accepted for backward compatibility.

For example, the following token encrypts the payload `{"uid":10000,"uname":"test"}` for the Consumer key `jack-key` with the secret configured above:

```text
eyJraWQiOiJqYWNrLWtleSIsImFsZyI6ImRpciIsImVuYyI6IkEyNTZHQ00ifQ..vi29KBCQKcVmPwTT.VToyPMFbq-ZY05MIpntP1N3AmYeq3zELQ0B6iQ.vuTPG2ODc-DjUTjNCzfA2A
```

### Decrypt Data from the Plugin Token
### Decrypt Data with JWE

The following example demonstrates how to decrypt the plugin token generated above.
The following example demonstrates how to decrypt the JWE token generated above.

Create a Route with `jwe-decrypt` to decrypt the authorization header:

Expand Down Expand Up @@ -320,7 +322,7 @@ kubectl apply -f jwe-decrypt-ic.yaml
</TabItem>
</Tabs>

Send a request to the Route with the encrypted plugin token in the `Authorization` header:
Send a request to the Route with the JWE encrypted data in the `Authorization` header:

```shell
curl "http://127.0.0.1:9080/anything/jwe" -H 'Authorization: eyJraWQiOiJqYWNrLWtleSIsImFsZyI6ImRpciIsImVuYyI6IkEyNTZHQ00ifQ..vi29KBCQKcVmPwTT.VToyPMFbq-ZY05MIpntP1N3AmYeq3zELQ0B6iQ.vuTPG2ODc-DjUTjNCzfA2A'
Expand Down
6 changes: 4 additions & 2 deletions docs/zh/latest/plugins/jwe-decrypt.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,15 @@ kubectl apply -f jwe-consumer-ic.yaml
</TabItem>
</Tabs>

要为消费者生成 JWE 令牌,可使用任意 AES-256-GCM 库离线加密 payload,加密密钥为消费者的 secret。令牌结构如下:
要为消费者生成 JWE 令牌,可使用任意支持 `A256GCM` 直接加密的 JWE 库,加密密钥为消费者的 secret。令牌结构如下:

```text
base64url(header).<empty>.base64url(iv).base64url(ciphertext).base64url(tag)
```

其中 header 为 `{"alg":"dir","enc":"A256GCM","kid":"<consumer-key>"}`。每个令牌的 IV 必须唯一且随机生成,切勿在同一密钥下复用 IV。
其中 header 为 `{"alg":"dir","enc":"A256GCM","kid":"<consumer-key>"}`,`alg` 与 `enc` 若为其他值则会被拒绝。每个令牌的 IV 必须唯一且随机生成,切勿在同一密钥下复用 IV。

按 [RFC 7516](https://datatracker.ietf.org/doc/html/rfc7516#section-5.1) 的要求,JWE 库会将编码后的 protected header 作为 AES-GCM 的附加认证数据(AAD)参与认证,从而使 `kid` 不可篡改。为保持向后兼容,未使用 AAD 加密的令牌(例如 APISIX 早期自行生成的令牌)仍然可以正常解密。

例如,以下令牌使用上面配置的 secret,为消费者密钥 `jack-key` 加密了 payload `{"uid":10000,"uname":"test"}`:

Expand Down
167 changes: 167 additions & 0 deletions t/plugin/jwe-decrypt.t
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,26 @@ fo4XKdZ1xSrIZyms4q2BwPrW5lMpls9qqy5tiAk2esc=
return
end

-- shares the secret of jwe_fail_user, so swapping a token kid to
-- this consumer isolates the AAD check from a key mismatch
code = t('/apisix/admin/consumers',
ngx.HTTP_PUT,
[[{
"username": "jwe_fail_twin",
"plugins": {
"jwe-decrypt": {
"key": "jwe-fail-key-twin",
"secret": "12345678901234567890123456789012"
}
}
}]]
)
if code >= 300 then
ngx.status = code
ngx.say("failed to add consumer")
return
end

code = t('/apisix/admin/routes/10',
ngx.HTTP_PUT,
[[{
Expand Down Expand Up @@ -762,3 +782,150 @@ status: 400
}
--- response_body
status: 400



=== TEST 31: RFC 7516 token authenticating the protected header is accepted
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test

-- generated with an independent JWE producer (python cryptography),
-- so the tag covers the encoded protected header as the AES-GCM AAD
local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0."
.. ".MTIzNDU2Nzg5MDEy.6JeRgm0.KaxbSD-kuYBVck03POSk7w"

local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
{ Authorization = "Bearer " .. token })
ngx.say("status: ", code)
}
}
--- response_body
status: 200



=== TEST 32: token without AAD is still accepted
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test

-- same payload, encrypted the way APISIX used to generate tokens
local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0."
.. ".MTIzNDU2Nzg5MDEy.6JeRgm0.rNt131nG5wMvUD1KXbwLGA"

local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
{ Authorization = "Bearer " .. token })
ngx.say("status: ", code)
}
}
--- response_body
status: 200



=== TEST 33: replacing the kid of an RFC 7516 token is rejected
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test

-- the TEST 31 token with its kid changed to jwe-fail-key-twin,
-- which holds the same secret: decryption can only fail because
-- the tag no longer covers the header
local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5LXR3aW4ifQ."
.. ".MTIzNDU2Nzg5MDEy.6JeRgm0.KaxbSD-kuYBVck03POSk7w"

local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
{ Authorization = "Bearer " .. token })
ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", "")))
}
}
--- response_body
status: 400 body: {"message":"failed to decrypt JWE token"}



=== TEST 34: unsupported alg is rejected
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local token = "eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiJqd2UtZmFpbC1rZXkifQ."
.. ".MTIzNDU2Nzg5MDEy.6JeRgm0.7QVBNAw7GFOQRLCtZWtdsA"

local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
{ Authorization = "Bearer " .. token })
ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", "")))
}
}
--- response_body
status: 400 body: {"message":"unsupported alg or enc in JWE token"}



=== TEST 35: unsupported enc is rejected
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local token = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0."
.. ".MTIzNDU2Nzg5MDEy.6JeRgm0.99OmOTEx2wPsqhsx0FjM8Q"

local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
{ Authorization = "Bearer " .. token })
ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", "")))
}
}
--- response_body
status: 400 body: {"message":"unsupported alg or enc in JWE token"}



=== TEST 36: token whose header omits alg and enc is still accepted
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test

-- the plugin never read alg or enc before, so a token minted with
-- a minimal header keeps working; only a header naming another
-- algorithm is rejected
local token = "eyJraWQiOiJqd2UtZmFpbC1rZXkifQ."
.. ".MTIzNDU2Nzg5MDEy.6JeRgm0.rNt131nG5wMvUD1KXbwLGA"

local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
{ Authorization = "Bearer " .. token })
ngx.say("status: ", code)
}
}
--- response_body
status: 200



=== TEST 37: header whose alg is a JSON false is rejected
--- config
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
local core = require("apisix.core")
local enc = require("ngx.base64").encode_base64url

-- a present but non-string alg is not an omitted alg, so the
-- backward compatible path must not swallow it
local header = enc(core.json.encode({
alg = false, enc = "A256GCM", kid = "jwe-fail-key",
}))
local token = header .. ".." .. enc("123456789012") .. "."
.. enc("undecryptable") .. "." .. enc("0123456789abcdef")

local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
{ Authorization = "Bearer " .. token })
ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", "")))
}
}
--- response_body
status: 400 body: {"message":"unsupported alg or enc in JWE token"}
Loading