1use 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
29const AUTH_TYPE: &str = "jwe-decrypt";
31
32fn 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#[cfg(test)]
43fn b64url_encode(b: &[u8]) -> String {
44 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b)
45}
46
47fn 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
61fn 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 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
86pub struct JweDecryptPlugin {
95 header: String,
97 forward_header: String,
99 strict: bool,
102 inline_key: Option<Vec<u8>>,
105 #[allow(dead_code)] use_consumers: bool,
108 resources: Arc<PluginResources>,
109}
110
111impl JweDecryptPlugin {
112 pub fn from_config(
142 config: &HashMap<String, serde_json::Value>,
143 resources: &Arc<PluginResources>,
144 ) -> Result<Self, String> {
145 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 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 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 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 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 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 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 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 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 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 let raw_secret = "0123456789abcdef0123456789abcdef"; 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 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 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}