featherbit/plugins/native/
jwe_decrypt.rs1use 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
28const AUTH_TYPE: &str = "jwe-decrypt";
30
31fn 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#[cfg(test)]
42fn b64url_encode(b: &[u8]) -> String {
43 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b)
44}
45
46fn 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
60fn 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 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
85pub struct JweDecryptPlugin {
94 header: String,
96 forward_header: String,
98 strict: bool,
101 inline_key: Option<Vec<u8>>,
104 #[allow(dead_code)] use_consumers: bool,
107 resources: Arc<PluginResources>,
108}
109
110impl JweDecryptPlugin {
111 pub fn from_config(
141 config: &HashMap<String, serde_json::Value>,
142 resources: &Arc<PluginResources>,
143 ) -> Result<Self, String> {
144 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 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 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 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 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 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 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 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 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 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 let raw_secret = "0123456789abcdef0123456789abcdef"; 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 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 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}