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