Skip to main content

featherbit/plugins/native/
jwe_decrypt.rs

1//! JWE decryption plugin (`jwe-decrypt`).
2//!
3//! Reads a JWE-encrypted token from a request header, decrypts it, and forwards
4//! the plaintext into another request header before the request is proxied
5//! upstream. This is a **faithful subset** of APISIX's `jwe-decrypt` plugin: it
6//! implements the `dir` (direct) key-management algorithm with `A256GCM`
7//! content encryption only — the exact scheme APISIX supports via its
8//! `resty.aes` 256-bit GCM cipher. Other JWE algorithms (RSA-OAEP, ECDH-ES,
9//! key-wrap variants, other content ciphers) are **not** supported; see the
10//! Deviations section of the docs page.
11//!
12//! The symmetric key is either configured inline (`key`, base64) or resolved
13//! per request from the consumer store (`use_consumers`) using the `kid`
14//! carried in the JWE protected header. Malformed tokens and decryption
15//! failures are rejected with a 401 routed through the node's `denied` port.
16
17use async_trait::async_trait;
18use base64::Engine;
19use bytes::Bytes;
20use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use crate::context::Context;
25use crate::plugins::resources::PluginResources;
26use crate::plugins::{Plugin, PluginOutput, PluginResult};
27
28/// The consumer-store auth type under which jwe-decrypt credentials are indexed.
29const AUTH_TYPE: &str = "jwe-decrypt";
30
31/// Decodes a base64url (RFC 4648 §5, no padding) string, tolerating any
32/// trailing `=` padding some encoders emit.
33fn b64url_decode(s: &str) -> Option<Vec<u8>> {
34    base64::engine::general_purpose::URL_SAFE_NO_PAD
35        .decode(s.trim_end_matches('='))
36        .ok()
37}
38
39/// Encodes bytes as base64url without padding. Test-only: used to build JWE
40/// fixtures.
41#[cfg(test)]
42fn b64url_encode(b: &[u8]) -> String {
43    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b)
44}
45
46/// Decodes a configured/consumer key that may be either standard or url-safe
47/// base64 (with or without padding).
48fn decode_config_key(s: &str) -> Option<Vec<u8>> {
49    base64::engine::general_purpose::STANDARD
50        .decode(s)
51        .ok()
52        .or_else(|| {
53            base64::engine::general_purpose::STANDARD_NO_PAD
54                .decode(s)
55                .ok()
56        })
57        .or_else(|| b64url_decode(s))
58}
59
60/// AES-256-GCM decryption for the `dir`+`A256GCM` JWE scheme. `aad` is the
61/// ASCII bytes of the base64url-encoded protected header (RFC 7516 §5.1).
62fn aes256gcm_decrypt(
63    key: &[u8],
64    iv: &[u8],
65    ciphertext: &[u8],
66    tag: &[u8],
67    aad: &[u8],
68) -> Option<Vec<u8>> {
69    if key.len() != 32 || iv.len() != 12 {
70        return None;
71    }
72    let unbound = UnboundKey::new(&AES_256_GCM, key).ok()?;
73    let sealing = LessSafeKey::new(unbound);
74    let nonce = Nonce::try_assume_unique_for_key(iv).ok()?;
75    // ring expects ciphertext followed by the authentication tag in one buffer.
76    let mut in_out = Vec::with_capacity(ciphertext.len() + tag.len());
77    in_out.extend_from_slice(ciphertext);
78    in_out.extend_from_slice(tag);
79    let plaintext = sealing
80        .open_in_place(nonce, Aad::from(aad), &mut in_out)
81        .ok()?;
82    Some(plaintext.to_vec())
83}
84
85/// Decrypts a JWE `dir`+`A256GCM` token and forwards the plaintext downstream.
86///
87/// On success the decrypted plaintext replaces the value of `forward_header`
88/// in the outbound request; the request otherwise continues unchanged through
89/// the **success** port. On any failure (missing token when `strict`,
90/// malformed compact serialization, unsupported algorithm, unknown `kid`, or a
91/// failed AEAD check) the request is rejected with a 401 JSON response and
92/// exits through the **denied** port.
93pub struct JweDecryptPlugin {
94    /// Lowercased header the encrypted token is read from.
95    header: String,
96    /// Lowercased header the decrypted plaintext is written to.
97    forward_header: String,
98    /// When true, a missing token is rejected; when false the request passes
99    /// through untouched.
100    strict: bool,
101    /// Inline symmetric key (32 raw bytes) when configured; takes precedence
102    /// over the consumer store.
103    inline_key: Option<Vec<u8>>,
104    /// When true, resolve the key from the consumer store using the token `kid`.
105    #[allow(dead_code)] // parsed config; consumer-store lookup not yet wired
106    use_consumers: bool,
107    resources: Arc<PluginResources>,
108}
109
110impl JweDecryptPlugin {
111    /// Builds the plugin from node config.
112    ///
113    /// Accepted keys:
114    /// - `header` (string, default `"Authorization"`): header carrying the JWE
115    ///   token; matched case-insensitively (an optional `Bearer ` prefix is
116    ///   stripped).
117    /// - `forward_header` (string, default `"Authorization"`): header the
118    ///   decrypted plaintext is written to before proxying.
119    /// - `strict` (bool, default `true`): when true a missing token is
120    ///   rejected; when false the request passes through unchanged.
121    /// - `key` (string, optional): inline symmetric key, base64-encoded, which
122    ///   must decode to exactly 32 bytes (AES-256). Used for every request.
123    /// - `use_consumers` (bool, default `false`): resolve the key per request
124    ///   from the gateway `consumers:` section — the token's `kid` selects the
125    ///   consumer's `jwe-decrypt` credential (`{key: <kid>, secret: <32-byte
126    ///   key>, is_base64_encoded: <bool>}`).
127    /// - At least one of `key` / `use_consumers` must be provided.
128    /// - `alg` (string, default `"dir"`) / `enc` (string, default `"A256GCM"`):
129    ///   only these values are supported; any other value is a **config error**
130    ///   (fail-fast at load), since full JWE is not implemented.
131    ///
132    /// ```yaml
133    /// type: jwe-decrypt
134    /// config:
135    ///   header: Authorization
136    ///   forward_header: Authorization
137    ///   strict: true
138    ///   use_consumers: true
139    /// ```
140    pub fn from_config(
141        config: &HashMap<String, serde_json::Value>,
142        resources: &Arc<PluginResources>,
143    ) -> Result<Self, String> {
144        // Reject unsupported JWE schemes at load time (faithful-subset guard).
145        if let Some(alg) = config.get("alg").and_then(|v| v.as_str()) {
146            if alg != "dir" {
147                return Err(format!(
148                    "jwe-decrypt only supports alg 'dir' (got '{}'); RSA/ECDH/key-wrap are not implemented",
149                    alg
150                ));
151            }
152        }
153        if let Some(enc) = config.get("enc").and_then(|v| v.as_str()) {
154            if enc != "A256GCM" {
155                return Err(format!(
156                    "jwe-decrypt only supports enc 'A256GCM' (got '{}')",
157                    enc
158                ));
159            }
160        }
161
162        let header = config
163            .get("header")
164            .and_then(|v| v.as_str())
165            .unwrap_or("Authorization")
166            .to_lowercase();
167
168        let forward_header = config
169            .get("forward_header")
170            .and_then(|v| v.as_str())
171            .unwrap_or("Authorization")
172            .to_lowercase();
173
174        let strict = config
175            .get("strict")
176            .and_then(|v| v.as_bool())
177            .unwrap_or(true);
178
179        let inline_key = match config.get("key").and_then(|v| v.as_str()) {
180            Some(k) => {
181                let bytes = decode_config_key(k)
182                    .ok_or_else(|| "jwe-decrypt 'key' must be valid base64".to_string())?;
183                if bytes.len() != 32 {
184                    return Err(format!(
185                        "jwe-decrypt 'key' must decode to 32 bytes for AES-256 (got {})",
186                        bytes.len()
187                    ));
188                }
189                Some(bytes)
190            }
191            None => None,
192        };
193
194        let use_consumers = config
195            .get("use_consumers")
196            .and_then(|v| v.as_bool())
197            .unwrap_or(false);
198
199        if inline_key.is_none() && !use_consumers {
200            return Err("jwe-decrypt plugin requires 'key' or 'use_consumers: true'".to_string());
201        }
202
203        Ok(Self {
204            header,
205            forward_header,
206            strict,
207            inline_key,
208            use_consumers,
209            resources: resources.clone(),
210        })
211    }
212
213    /// Builds the 401 rejection and exits on the `denied` port.
214    fn reject(ctx: Context, message: &str) -> PluginResult {
215        let mut ctx = ctx;
216        ctx.response.status_code = 401;
217        ctx.response.body = Bytes::from(format!(
218            r#"{{"error": "unauthorized", "message": "{}"}}"#,
219            message
220        ));
221        ctx.response.headers.insert(
222            "content-type".to_string(),
223            vec!["application/json".to_string()],
224        );
225        Ok(PluginOutput::on_port(ctx, "denied"))
226    }
227
228    /// Resolves the 32-byte AES key for the given token `kid` from the consumer
229    /// store. Returns the raw key bytes, or `None` with a reason.
230    fn consumer_key(&self, kid: Option<&str>) -> Result<Vec<u8>, &'static str> {
231        let kid = kid.ok_or("missing kid in JWE token")?;
232        let store = self.resources.consumers.load();
233        let consumer = store
234            .find_by_credential(AUTH_TYPE, kid)
235            .ok_or("invalid kid in JWE token")?;
236        let cred = consumer
237            .credentials
238            .get(AUTH_TYPE)
239            .ok_or("consumer has no jwe-decrypt credential")?;
240        let secret = cred
241            .get("secret")
242            .and_then(|v| v.as_str())
243            .ok_or("consumer jwe-decrypt credential missing 'secret'")?;
244        let is_b64 = cred
245            .get("is_base64_encoded")
246            .and_then(|v| v.as_bool())
247            .unwrap_or(false);
248        let bytes = if is_b64 {
249            b64url_decode(secret).ok_or("consumer secret is not valid base64url")?
250        } else {
251            secret.as_bytes().to_vec()
252        };
253        if bytes.len() != 32 {
254            return Err("consumer secret must be 32 bytes for AES-256");
255        }
256        Ok(bytes)
257    }
258}
259
260#[async_trait]
261impl Plugin for JweDecryptPlugin {
262    fn plugin_type(&self) -> &str {
263        "jwe-decrypt"
264    }
265
266    async fn execute(&self, mut ctx: Context) -> PluginResult {
267        // Fetch the token, stripping a Bearer prefix (case-insensitive).
268        let raw = ctx
269            .request
270            .headers
271            .get(&self.header)
272            .and_then(|v| v.first())
273            .cloned();
274        let token = match raw {
275            Some(t) => {
276                let lower = t.to_ascii_lowercase();
277                if lower.starts_with("bearer ") {
278                    t[7..].to_string()
279                } else {
280                    t
281                }
282            }
283            None => {
284                if self.strict {
285                    return Self::reject(ctx, "missing JWE token in request");
286                }
287                return Ok(PluginOutput::success(ctx));
288            }
289        };
290
291        // Parse the 5-part compact serialization:
292        // protected . encrypted_key . iv . ciphertext . tag
293        let parts: Vec<&str> = token.split('.').collect();
294        if parts.len() != 5 {
295            return Self::reject(ctx, "malformed JWE token");
296        }
297        let (protected_b64, enc_key, iv_b64, ct_b64, tag_b64) =
298            (parts[0], parts[1], parts[2], parts[3], parts[4]);
299
300        // dir: the encrypted_key segment must be empty.
301        if !enc_key.is_empty() {
302            return Self::reject(
303                ctx,
304                "unsupported JWE: encrypted key present (only dir is supported)",
305            );
306        }
307
308        let header_bytes = match b64url_decode(protected_b64) {
309            Some(b) => b,
310            None => return Self::reject(ctx, "malformed JWE protected header"),
311        };
312        let header_obj: serde_json::Value = match serde_json::from_slice(&header_bytes) {
313            Ok(v) => v,
314            Err(_) => return Self::reject(ctx, "malformed JWE protected header"),
315        };
316
317        // Enforce the supported scheme.
318        if header_obj.get("alg").and_then(|v| v.as_str()) != Some("dir")
319            || header_obj.get("enc").and_then(|v| v.as_str()) != Some("A256GCM")
320        {
321            return Self::reject(
322                ctx,
323                "unsupported JWE alg/enc (only dir + A256GCM supported)",
324            );
325        }
326
327        let iv = match b64url_decode(iv_b64) {
328            Some(v) => v,
329            None => return Self::reject(ctx, "malformed JWE iv"),
330        };
331        let ciphertext = match b64url_decode(ct_b64) {
332            Some(v) => v,
333            None => return Self::reject(ctx, "malformed JWE ciphertext"),
334        };
335        let tag = match b64url_decode(tag_b64) {
336            Some(v) => v,
337            None => return Self::reject(ctx, "malformed JWE tag"),
338        };
339
340        // Resolve the symmetric key.
341        let key = if let Some(ref k) = self.inline_key {
342            k.clone()
343        } else {
344            match self.consumer_key(header_obj.get("kid").and_then(|v| v.as_str())) {
345                Ok(k) => k,
346                Err(reason) => return Self::reject(ctx, reason),
347            }
348        };
349
350        // AAD is the ASCII of the encoded protected header (RFC 7516 §5.1).
351        match aes256gcm_decrypt(&key, &iv, &ciphertext, &tag, protected_b64.as_bytes()) {
352            Some(plaintext) => {
353                let value = String::from_utf8_lossy(&plaintext).into_owned();
354                ctx.request
355                    .headers
356                    .insert(self.forward_header.clone(), vec![value]);
357                Ok(PluginOutput::success(ctx))
358            }
359            None => Self::reject(ctx, "failed to decrypt JWE token"),
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::consumers::{ConsumerConfig, ConsumerStore};
368    use crate::context::{GatewayRequest, Protocol};
369
370    fn ctx_with_header(name: &str, value: Option<&str>) -> Context {
371        let mut headers = HashMap::new();
372        if let Some(v) = value {
373            headers.insert(name.to_string(), vec![v.to_string()]);
374        }
375        Context::new(GatewayRequest {
376            method: "GET".into(),
377            path: "/".into(),
378            host: "h".into(),
379            scheme: "http".into(),
380            headers,
381            query_params: HashMap::new(),
382            body: Bytes::new(),
383            remote_addr: "1.2.3.4:5".into(),
384            protocol: Protocol::Http1,
385        })
386    }
387
388    /// Encrypts `plaintext` into a compact `dir`+`A256GCM` JWE with an optional
389    /// `kid`, using the same primitives the plugin decrypts with.
390    fn make_jwe(key: &[u8; 32], iv: &[u8; 12], plaintext: &[u8], kid: Option<&str>) -> String {
391        let header_json = match kid {
392            Some(k) => format!(r#"{{"alg":"dir","enc":"A256GCM","kid":"{}"}}"#, k),
393            None => r#"{"alg":"dir","enc":"A256GCM"}"#.to_string(),
394        };
395        let protected_b64 = b64url_encode(header_json.as_bytes());
396
397        let unbound = UnboundKey::new(&AES_256_GCM, key).unwrap();
398        let sealing = LessSafeKey::new(unbound);
399        let nonce = Nonce::try_assume_unique_for_key(iv).unwrap();
400        let mut in_out = plaintext.to_vec();
401        sealing
402            .seal_in_place_append_tag(nonce, Aad::from(protected_b64.as_bytes()), &mut in_out)
403            .unwrap();
404        let (ct, tag) = in_out.split_at(plaintext.len());
405
406        format!(
407            "{}..{}.{}.{}",
408            protected_b64,
409            b64url_encode(iv),
410            b64url_encode(ct),
411            b64url_encode(tag)
412        )
413    }
414
415    #[test]
416    fn test_requires_key_or_consumers() {
417        assert!(JweDecryptPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
418    }
419
420    #[test]
421    fn test_rejects_unsupported_alg_at_load() {
422        let mut cfg = HashMap::new();
423        cfg.insert(
424            "key".to_string(),
425            serde_json::json!(base64::engine::general_purpose::STANDARD.encode([0u8; 32])),
426        );
427        cfg.insert("alg".to_string(), serde_json::json!("RSA-OAEP"));
428        assert!(JweDecryptPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
429    }
430
431    #[tokio::test]
432    async fn test_roundtrip_inline_key() {
433        let key = [7u8; 32];
434        let iv = [3u8; 12];
435        let token = make_jwe(&key, &iv, b"hello-plaintext", None);
436
437        let mut cfg = HashMap::new();
438        cfg.insert(
439            "key".to_string(),
440            serde_json::json!(base64::engine::general_purpose::STANDARD.encode(key)),
441        );
442        cfg.insert(
443            "forward_header".to_string(),
444            serde_json::json!("x-decrypted"),
445        );
446        let plugin = JweDecryptPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
447
448        let ctx = ctx_with_header("authorization", Some(&format!("Bearer {}", token)));
449        let out = plugin.execute(ctx).await.unwrap();
450        assert_eq!(
451            out.context.request.headers.get("x-decrypted"),
452            Some(&vec!["hello-plaintext".to_string()])
453        );
454    }
455
456    #[tokio::test]
457    async fn test_roundtrip_consumer_key() {
458        let iv = [1u8; 12];
459        // Use a raw 32-char secret so the is_base64_encoded=false path is exercised.
460        // Fixture: exactly 32 bytes, not a credential.
461        let raw_secret = "0123456789abcdef0123456789abcdef"; // nosemgrep: generic.secrets.security.detected-generic-secret.detected-generic-secret
462        let key32: [u8; 32] = raw_secret.as_bytes().try_into().unwrap();
463        let token = make_jwe(&key32, &iv, b"consumer-body", Some("kid-1"));
464
465        let resources = PluginResources::empty();
466        let consumers: Vec<ConsumerConfig> = serde_json::from_value(serde_json::json!([
467            {
468                "name": "c1",
469                "credentials": { "jwe-decrypt": { "key": "kid-1", "secret": raw_secret } }
470            }
471        ]))
472        .unwrap();
473        resources
474            .consumers
475            .store(Arc::new(ConsumerStore::from_config(&consumers).unwrap()));
476
477        let mut cfg = HashMap::new();
478        cfg.insert("use_consumers".to_string(), serde_json::json!(true));
479        cfg.insert(
480            "forward_header".to_string(),
481            serde_json::json!("x-decrypted"),
482        );
483        let plugin = JweDecryptPlugin::from_config(&cfg, &resources).unwrap();
484
485        let ctx = ctx_with_header("authorization", Some(&token));
486        let out = plugin.execute(ctx).await.unwrap();
487        assert_eq!(
488            out.context.request.headers.get("x-decrypted"),
489            Some(&vec!["consumer-body".to_string()])
490        );
491    }
492
493    #[tokio::test]
494    async fn test_malformed_token_rejected() {
495        let key = [7u8; 32];
496        let mut cfg = HashMap::new();
497        cfg.insert(
498            "key".to_string(),
499            serde_json::json!(base64::engine::general_purpose::STANDARD.encode(key)),
500        );
501        let plugin = JweDecryptPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
502
503        // Only 4 segments -> malformed.
504        let ctx = ctx_with_header("authorization", Some("not.a.valid.jwe"));
505        let out = plugin.execute(ctx).await.unwrap();
506        assert_eq!(out.port, Some("denied"));
507        assert_eq!(out.context.response.status_code, 401);
508    }
509
510    #[tokio::test]
511    async fn test_tampered_ciphertext_fails_aead() {
512        let key = [5u8; 32];
513        let iv = [2u8; 12];
514        let token = make_jwe(&key, &iv, b"secret", None);
515        // Corrupt the ciphertext segment.
516        let mut parts: Vec<&str> = token.split('.').collect();
517        let bad_ct = "AAAAAAAA";
518        parts[3] = bad_ct;
519        let tampered = parts.join(".");
520
521        let mut cfg = HashMap::new();
522        cfg.insert(
523            "key".to_string(),
524            serde_json::json!(base64::engine::general_purpose::STANDARD.encode(key)),
525        );
526        let plugin = JweDecryptPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
527        let ctx = ctx_with_header("authorization", Some(&tampered));
528        let out = plugin.execute(ctx).await.unwrap();
529        assert_eq!(out.port, Some("denied"));
530    }
531
532    #[tokio::test]
533    async fn test_missing_token_non_strict_passthrough() {
534        let key = [7u8; 32];
535        let mut cfg = HashMap::new();
536        cfg.insert(
537            "key".to_string(),
538            serde_json::json!(base64::engine::general_purpose::STANDARD.encode(key)),
539        );
540        cfg.insert("strict".to_string(), serde_json::json!(false));
541        let plugin = JweDecryptPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
542        let ctx = ctx_with_header("authorization", None);
543        let out = plugin.execute(ctx).await.unwrap();
544        assert_eq!(out.port, None);
545    }
546}