1use std::sync::Arc;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use axum::Json;
25use axum::body::Body;
26use axum::extract::State;
27use axum::http::{Request, StatusCode, header};
28use axum::middleware::Next;
29use axum::response::IntoResponse;
30use base64::Engine;
31use base64::engine::general_purpose::URL_SAFE_NO_PAD;
32use ed25519_dalek::{Signature, VerifyingKey};
33use scp_core::bridge::{BridgeConnector, BridgeStatus};
34use scp_identity::dht::decode_multibase_key;
35use scp_identity::document::DidDocument;
36use serde::{Deserialize, Serialize};
37
38use crate::error::ApiError;
39
40const MAX_TOKEN_LIFETIME_SECS: u64 = 3600;
48
49const CLOCK_SKEW_TOLERANCE_SECS: u64 = 30;
53
54const JWT_ALG_EDDSA: &str = "EdDSA";
56
57const JWT_TYP: &str = "JWT";
59
60fn bridge_not_authorized(msg: impl Into<String>) -> (StatusCode, Json<ApiError>) {
69 (
70 StatusCode::UNAUTHORIZED,
71 Json(ApiError {
72 error: msg.into(),
73 code: "BRIDGE_NOT_AUTHORIZED".to_owned(),
74 }),
75 )
76}
77
78fn bridge_suspended(msg: impl Into<String>) -> (StatusCode, Json<ApiError>) {
83 (
84 StatusCode::FORBIDDEN,
85 Json(ApiError {
86 error: msg.into(),
87 code: "BRIDGE_SUSPENDED".to_owned(),
88 }),
89 )
90}
91
92#[derive(Debug, Deserialize)]
100struct JwtHeader {
101 alg: String,
103
104 #[serde(default)]
106 typ: Option<String>,
107
108 #[serde(default)]
111 kid: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct BridgeJwtClaims {
122 pub iss: String,
124
125 pub aud: String,
127
128 pub iat: u64,
130
131 pub exp: u64,
133
134 pub scp_bridge_id: String,
136
137 pub scp_context_id: String,
139}
140
141#[derive(Debug, Clone)]
146pub struct BridgeAuthContext {
147 pub claims: BridgeJwtClaims,
149
150 pub bridge: BridgeConnector,
152}
153
154pub trait BridgeLookup: Send + Sync + 'static {
164 fn find_bridge(&self, bridge_id: &str) -> Option<BridgeConnector>;
168
169 fn resolve_did_document(&self, did: &str) -> Option<DidDocument>;
174
175 fn find_webhook_key(&self, key_id: &str) -> Option<[u8; 32]>;
180
181 fn expected_audience(&self) -> &str;
183}
184
185fn decode_jwt_segment(segment: &str) -> Result<Vec<u8>, String> {
191 URL_SAFE_NO_PAD
192 .decode(segment)
193 .map_err(|e| format!("invalid base64url encoding: {e}"))
194}
195
196fn verify_bridge_jwt(token: &str, lookup: &dyn BridgeLookup) -> Result<BridgeJwtClaims, String> {
211 let parts: Vec<&str> = token.split('.').collect();
213 if parts.len() != 3 {
214 return Err("JWT must have exactly three segments".to_owned());
215 }
216
217 let header_b64 = parts[0];
218 let payload_b64 = parts[1];
219 let signature_b64 = parts[2];
220
221 let header_bytes = decode_jwt_segment(header_b64)?;
223 let header: JwtHeader = serde_json::from_slice(&header_bytes)
224 .map_err(|e| format!("invalid JWT header JSON: {e}"))?;
225
226 if header.alg != JWT_ALG_EDDSA {
227 return Err(format!(
228 "unsupported JWT algorithm: expected {JWT_ALG_EDDSA}, got {}",
229 header.alg
230 ));
231 }
232
233 if let Some(ref typ) = header.typ
234 && !typ.eq_ignore_ascii_case(JWT_TYP)
235 {
236 return Err(format!(
237 "unsupported JWT type: expected {JWT_TYP}, got {typ}"
238 ));
239 }
240
241 let payload_bytes = decode_jwt_segment(payload_b64)?;
243 let claims: BridgeJwtClaims = serde_json::from_slice(&payload_bytes)
244 .map_err(|e| format!("invalid JWT payload JSON: {e}"))?;
245
246 let did_doc = lookup
248 .resolve_did_document(&claims.iss)
249 .ok_or_else(|| format!("could not resolve DID document for issuer: {}", claims.iss))?;
250
251 let fragment = header.kid.as_ref().map_or_else(
259 || "active".to_owned(),
260 |kid| {
261 kid.strip_prefix('#').map_or_else(
262 || {
263 kid.rsplit_once('#')
264 .map_or_else(|| kid.clone(), |(_, f)| (*f).to_owned())
265 },
266 str::to_owned,
267 )
268 },
269 );
270
271 let vm = did_doc
272 .verification_method_by_fragment(&fragment)
273 .ok_or_else(|| {
274 format!(
275 "DID document for {} has no verification method with fragment #{fragment}",
276 claims.iss
277 )
278 })?;
279
280 let pub_key_bytes = decode_multibase_key(&vm.public_key_multibase)
281 .map_err(|e| format!("failed to decode public key from DID document: {e}"))?;
282
283 let verifying_key = VerifyingKey::from_bytes(&pub_key_bytes)
284 .map_err(|e| format!("invalid Ed25519 public key in DID document: {e}"))?;
285
286 let signature_bytes = decode_jwt_segment(signature_b64)?;
288 let signature_array: [u8; 64] = signature_bytes.try_into().map_err(|v: Vec<u8>| {
289 format!(
290 "invalid Ed25519 signature length: expected 64, got {}",
291 v.len()
292 )
293 })?;
294 let signature = Signature::from_bytes(&signature_array);
295
296 let signing_input = format!("{header_b64}.{payload_b64}");
298 verifying_key
299 .verify_strict(signing_input.as_bytes(), &signature)
300 .map_err(|e| format!("JWT signature verification failed: {e}"))?;
301
302 let now = SystemTime::now()
304 .duration_since(UNIX_EPOCH)
305 .map(|d| d.as_secs())
306 .map_err(|_| "system clock is before Unix epoch".to_owned())?;
307
308 if claims.exp + CLOCK_SKEW_TOLERANCE_SECS < now {
310 return Err(format!("JWT has expired: exp={}, now={now}", claims.exp));
311 }
312
313 if claims.iat > now + CLOCK_SKEW_TOLERANCE_SECS {
315 return Err(format!(
316 "JWT issued in the future: iat={}, now={now}",
317 claims.iat
318 ));
319 }
320
321 let lifetime = claims.exp.saturating_sub(claims.iat);
323 if lifetime > MAX_TOKEN_LIFETIME_SECS {
324 return Err(format!(
325 "JWT lifetime exceeds maximum: {lifetime}s > {MAX_TOKEN_LIFETIME_SECS}s"
326 ));
327 }
328
329 if claims.aud != lookup.expected_audience() {
331 return Err(format!(
332 "JWT audience mismatch: expected {}, got {}",
333 lookup.expected_audience(),
334 claims.aud
335 ));
336 }
337
338 Ok(claims)
339}
340
341pub async fn bridge_auth_middleware<L: BridgeLookup>(
364 State(lookup): State<Arc<L>>,
365 mut req: Request<Body>,
366 next: Next,
367) -> impl IntoResponse {
368 let auth_header = req
370 .headers()
371 .get(header::AUTHORIZATION)
372 .and_then(|v| v.to_str().ok());
373
374 let token = match auth_header {
375 Some(value) if value.len() > 7 && value[..7].eq_ignore_ascii_case("bearer ") => &value[7..],
376 _ => {
377 return bridge_not_authorized("missing or invalid Authorization header")
378 .into_response();
379 }
380 };
381
382 let claims = match verify_bridge_jwt(token, lookup.as_ref()) {
384 Ok(claims) => claims,
385 Err(msg) => {
386 return bridge_not_authorized(msg).into_response();
387 }
388 };
389
390 let Some(bridge) = lookup.find_bridge(&claims.scp_bridge_id) else {
392 return bridge_not_authorized(format!("bridge not found: {}", claims.scp_bridge_id))
393 .into_response();
394 };
395
396 if bridge.operator_did != claims.iss {
398 return bridge_not_authorized("JWT issuer does not match bridge operator DID")
399 .into_response();
400 }
401
402 if claims.scp_context_id != bridge.registration_context {
404 return bridge_not_authorized("JWT context ID does not match bridge registration context")
405 .into_response();
406 }
407
408 match bridge.status {
410 BridgeStatus::Active => {}
411 BridgeStatus::Suspended => {
412 return bridge_suspended(format!(
413 "bridge {} is suspended by context governance",
414 bridge.bridge_id
415 ))
416 .into_response();
417 }
418 BridgeStatus::Revoked => {
419 return bridge_not_authorized(format!("bridge {} has been revoked", bridge.bridge_id))
420 .into_response();
421 }
422 }
423
424 let auth_ctx = BridgeAuthContext { claims, bridge };
426 req.extensions_mut().insert(auth_ctx);
427
428 next.run(req).await.into_response()
429}
430
431pub fn verify_webhook_signature(
447 signature_header: &str,
448 key_id: &str,
449 body: &[u8],
450 lookup: &dyn BridgeLookup,
451) -> Result<(), String> {
452 let pub_key_bytes = lookup
454 .find_webhook_key(key_id)
455 .ok_or_else(|| format!("unknown platform key ID: {key_id}"))?;
456
457 let verifying_key = VerifyingKey::from_bytes(&pub_key_bytes)
458 .map_err(|e| format!("invalid platform public key: {e}"))?;
459
460 let sig_bytes = URL_SAFE_NO_PAD
462 .decode(signature_header)
463 .map_err(|e| format!("invalid signature encoding: {e}"))?;
464
465 let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|v: Vec<u8>| {
466 format!(
467 "invalid Ed25519 signature length: expected 64, got {}",
468 v.len()
469 )
470 })?;
471 let signature = Signature::from_bytes(&sig_array);
472
473 verifying_key
474 .verify_strict(body, &signature)
475 .map_err(|e| format!("webhook signature verification failed: {e}"))
476}
477
478pub async fn webhook_auth_middleware<L: BridgeLookup>(
488 State(lookup): State<Arc<L>>,
489 req: Request<Body>,
490 next: Next,
491) -> impl IntoResponse {
492 let signature_header = match req
494 .headers()
495 .get("x-scp-signature")
496 .and_then(|v| v.to_str().ok())
497 {
498 Some(s) => s.to_owned(),
499 None => {
500 return bridge_not_authorized("missing X-SCP-Signature header").into_response();
501 }
502 };
503
504 let key_id = match req
505 .headers()
506 .get("x-scp-platform-key-id")
507 .and_then(|v| v.to_str().ok())
508 {
509 Some(k) => k.to_owned(),
510 None => {
511 return bridge_not_authorized("missing X-SCP-Platform-Key-Id header").into_response();
512 }
513 };
514
515 let (parts, body) = req.into_parts();
518 let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await {
519 Ok(b) => b,
520 Err(e) => {
521 return bridge_not_authorized(format!("failed to read request body: {e}"))
522 .into_response();
523 }
524 };
525
526 if let Err(msg) =
528 verify_webhook_signature(&signature_header, &key_id, &body_bytes, lookup.as_ref())
529 {
530 return bridge_not_authorized(msg).into_response();
531 }
532
533 let req = Request::from_parts(parts, Body::from(body_bytes));
535 next.run(req).await.into_response()
536}
537
538pub fn create_bridge_jwt(
557 claims: &BridgeJwtClaims,
558 signing_key: &ed25519_dalek::SigningKey,
559) -> Result<String, String> {
560 use ed25519_dalek::Signer;
561
562 let header = serde_json::json!({
563 "alg": JWT_ALG_EDDSA,
564 "typ": JWT_TYP
565 });
566
567 let header_b64 = URL_SAFE_NO_PAD.encode(
568 serde_json::to_vec(&header).map_err(|e| format!("header serialization failed: {e}"))?,
569 );
570 let payload_b64 = URL_SAFE_NO_PAD.encode(
571 serde_json::to_vec(claims).map_err(|e| format!("payload serialization failed: {e}"))?,
572 );
573
574 let signing_input = format!("{header_b64}.{payload_b64}");
575 let signature = signing_key.sign(signing_input.as_bytes());
576 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
577
578 Ok(format!("{header_b64}.{payload_b64}.{sig_b64}"))
579}
580
581#[cfg(test)]
586#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
587mod tests {
588 use super::*;
589
590 use axum::Router;
591 use axum::body::Body;
592 use axum::http::{Request, StatusCode};
593 use axum::middleware;
594 use axum::routing::get;
595 use ed25519_dalek::SigningKey;
596 use http_body_util::BodyExt;
597 use rand::rngs::OsRng;
598 use scp_core::bridge::{BridgeConnector, BridgeMode, BridgeStatus};
599 use scp_identity::document::{DidDocument, VerificationMethod};
600 use tower::ServiceExt;
601
602 struct TestBridgeLookup {
608 bridges: Vec<BridgeConnector>,
609 did_docs: Vec<(String, DidDocument)>,
610 webhook_keys: Vec<(String, [u8; 32])>,
611 audience: String,
612 }
613
614 impl TestBridgeLookup {
615 fn new(audience: &str) -> Self {
616 Self {
617 bridges: Vec::new(),
618 did_docs: Vec::new(),
619 webhook_keys: Vec::new(),
620 audience: audience.to_owned(),
621 }
622 }
623 }
624
625 impl BridgeLookup for TestBridgeLookup {
626 fn find_bridge(&self, bridge_id: &str) -> Option<BridgeConnector> {
627 self.bridges
628 .iter()
629 .find(|b| b.bridge_id == bridge_id)
630 .cloned()
631 }
632
633 fn resolve_did_document(&self, did: &str) -> Option<DidDocument> {
634 self.did_docs
635 .iter()
636 .find(|(d, _)| d == did)
637 .map(|(_, doc)| doc.clone())
638 }
639
640 fn find_webhook_key(&self, key_id: &str) -> Option<[u8; 32]> {
641 self.webhook_keys
642 .iter()
643 .find(|(id, _)| id == key_id)
644 .map(|(_, key)| *key)
645 }
646
647 fn expected_audience(&self) -> &str {
648 &self.audience
649 }
650 }
651
652 fn test_did(signing_key: &SigningKey) -> String {
657 let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
659 format!("did:dht:z6Mk{}", &pubkey_hex[..16])
660 }
661
662 fn test_did_document(did: &str, signing_key: &SigningKey) -> DidDocument {
663 let verifying = signing_key.verifying_key();
664 let pub_bytes = verifying.as_bytes();
665 let multibase = format!("z{}", bs58::encode(pub_bytes).into_string());
666
667 DidDocument {
668 context: vec!["https://www.w3.org/ns/did/v1".to_owned()],
669 id: did.to_owned(),
670 verification_method: vec![VerificationMethod {
671 id: format!("{did}#active"),
672 method_type: "Ed25519VerificationKey2020".to_owned(),
673 controller: did.to_owned(),
674 public_key_multibase: multibase,
675 }],
676 authentication: vec![format!("{did}#active")],
677 assertion_method: vec![format!("{did}#active")],
678 service: vec![],
679 also_known_as: Vec::new(),
680 }
681 }
682
683 fn test_bridge(
684 bridge_id: &str,
685 operator_did: &str,
686 context_id: &str,
687 status: BridgeStatus,
688 ) -> BridgeConnector {
689 BridgeConnector {
690 bridge_id: bridge_id.to_owned(),
691 operator_did: operator_did.into(),
692 platform: "discord".to_owned(),
693 mode: BridgeMode::Cooperative,
694 status,
695 registration_context: context_id.to_owned(),
696 registered_at: 1_700_000_000,
697 }
698 }
699
700 fn current_time() -> u64 {
701 SystemTime::now()
702 .duration_since(UNIX_EPOCH)
703 .map(|d| d.as_secs())
704 .unwrap_or(0)
705 }
706
707 fn test_claims(did: &str) -> BridgeJwtClaims {
708 let now = current_time();
709 BridgeJwtClaims {
710 iss: did.to_owned(),
711 aud: "https://node.example.com".to_owned(),
712 iat: now,
713 exp: now + 1800, scp_bridge_id: "bridge-test-001".to_owned(),
715 scp_context_id: "ctx-test-001".to_owned(),
716 }
717 }
718
719 fn test_app(lookup: Arc<TestBridgeLookup>) -> Router {
720 Router::new()
721 .route("/test", get(|| async { "ok" }))
722 .layer(middleware::from_fn_with_state(
723 lookup,
724 bridge_auth_middleware::<TestBridgeLookup>,
725 ))
726 }
727
728 async fn response_body(resp: axum::response::Response) -> String {
729 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
730 String::from_utf8(bytes.to_vec()).unwrap()
731 }
732
733 #[test]
738 fn create_and_verify_jwt_roundtrip() {
739 let signing_key = SigningKey::generate(&mut OsRng);
740 let did = test_did(&signing_key);
741 let claims = test_claims(&did);
742
743 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
744
745 let mut lookup = TestBridgeLookup::new("https://node.example.com");
746 lookup
747 .did_docs
748 .push((did.clone(), test_did_document(&did, &signing_key)));
749
750 let verified = verify_bridge_jwt(&token, &lookup).unwrap();
751 assert_eq!(verified.iss, did);
752 assert_eq!(verified.scp_bridge_id, "bridge-test-001");
753 assert_eq!(verified.scp_context_id, "ctx-test-001");
754 }
755
756 #[test]
757 fn reject_expired_jwt() {
758 let signing_key = SigningKey::generate(&mut OsRng);
759 let did = test_did(&signing_key);
760 let mut claims = test_claims(&did);
761 claims.iat = current_time() - 7200;
763 claims.exp = current_time() - 120;
764
765 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
766
767 let mut lookup = TestBridgeLookup::new("https://node.example.com");
768 lookup
769 .did_docs
770 .push((did.clone(), test_did_document(&did, &signing_key)));
771
772 let result = verify_bridge_jwt(&token, &lookup);
773 assert!(result.is_err());
774 assert!(
775 result.unwrap_err().contains("expired"),
776 "error should mention expiration"
777 );
778 }
779
780 #[test]
781 fn reject_jwt_with_excessive_lifetime() {
782 let signing_key = SigningKey::generate(&mut OsRng);
783 let did = test_did(&signing_key);
784 let mut claims = test_claims(&did);
785 claims.exp = claims.iat + 7200;
787
788 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
789
790 let mut lookup = TestBridgeLookup::new("https://node.example.com");
791 lookup
792 .did_docs
793 .push((did.clone(), test_did_document(&did, &signing_key)));
794
795 let result = verify_bridge_jwt(&token, &lookup);
796 assert!(result.is_err());
797 assert!(
798 result.unwrap_err().contains("lifetime exceeds maximum"),
799 "error should mention lifetime"
800 );
801 }
802
803 #[test]
804 fn reject_jwt_with_wrong_key() {
805 let signing_key = SigningKey::generate(&mut OsRng);
806 let wrong_key = SigningKey::generate(&mut OsRng);
807 let did = test_did(&signing_key);
808 let claims = test_claims(&did);
809
810 let token = create_bridge_jwt(&claims, &wrong_key).unwrap();
812
813 let mut lookup = TestBridgeLookup::new("https://node.example.com");
814 lookup
815 .did_docs
816 .push((did.clone(), test_did_document(&did, &signing_key)));
817
818 let result = verify_bridge_jwt(&token, &lookup);
819 assert!(result.is_err());
820 assert!(
821 result
822 .unwrap_err()
823 .contains("signature verification failed"),
824 "error should mention signature failure"
825 );
826 }
827
828 #[test]
829 fn reject_jwt_with_wrong_audience() {
830 let signing_key = SigningKey::generate(&mut OsRng);
831 let did = test_did(&signing_key);
832 let mut claims = test_claims(&did);
833 claims.aud = "https://wrong-node.example.com".to_owned();
834
835 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
836
837 let mut lookup = TestBridgeLookup::new("https://node.example.com");
838 lookup
839 .did_docs
840 .push((did.clone(), test_did_document(&did, &signing_key)));
841
842 let result = verify_bridge_jwt(&token, &lookup);
843 assert!(result.is_err());
844 assert!(
845 result.unwrap_err().contains("audience mismatch"),
846 "error should mention audience mismatch"
847 );
848 }
849
850 #[test]
851 fn reject_jwt_with_future_iat() {
852 let signing_key = SigningKey::generate(&mut OsRng);
853 let did = test_did(&signing_key);
854 let mut claims = test_claims(&did);
855 claims.iat = current_time() + 3600;
857 claims.exp = claims.iat + 1800;
858
859 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
860
861 let mut lookup = TestBridgeLookup::new("https://node.example.com");
862 lookup
863 .did_docs
864 .push((did.clone(), test_did_document(&did, &signing_key)));
865
866 let result = verify_bridge_jwt(&token, &lookup);
867 assert!(result.is_err());
868 assert!(
869 result.unwrap_err().contains("issued in the future"),
870 "error should mention future iat"
871 );
872 }
873
874 #[test]
875 fn reject_malformed_jwt() {
876 let lookup = TestBridgeLookup::new("https://node.example.com");
877
878 assert!(verify_bridge_jwt("not-a-jwt", &lookup).is_err());
880
881 assert!(verify_bridge_jwt("header.payload", &lookup).is_err());
883
884 assert!(verify_bridge_jwt("a.b.c.d", &lookup).is_err());
886 }
887
888 #[test]
889 fn reject_unsupported_algorithm() {
890 let header = serde_json::json!({"alg": "RS256", "typ": "JWT"});
891 let claims = serde_json::json!({"iss": "did:test", "aud": "test", "iat": 0, "exp": 0, "scp_bridge_id": "b", "scp_context_id": "c"});
892
893 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
894 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
895 let fake_sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
896 let token = format!("{header_b64}.{payload_b64}.{fake_sig}");
897
898 let lookup = TestBridgeLookup::new("test");
899 let result = verify_bridge_jwt(&token, &lookup);
900 assert!(result.is_err());
901 assert!(result.unwrap_err().contains("unsupported JWT algorithm"));
902 }
903
904 #[tokio::test]
909 async fn middleware_accepts_valid_jwt() {
910 let signing_key = SigningKey::generate(&mut OsRng);
911 let did = test_did(&signing_key);
912 let claims = test_claims(&did);
913 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
914
915 let mut lookup = TestBridgeLookup::new("https://node.example.com");
916 lookup
917 .did_docs
918 .push((did.clone(), test_did_document(&did, &signing_key)));
919 lookup.bridges.push(test_bridge(
920 "bridge-test-001",
921 &did,
922 "ctx-test-001",
923 BridgeStatus::Active,
924 ));
925 let lookup = Arc::new(lookup);
926
927 let app = test_app(lookup);
928 let req = Request::builder()
929 .uri("/test")
930 .header("Authorization", format!("Bearer {token}"))
931 .body(Body::empty())
932 .unwrap();
933
934 let resp = app.oneshot(req).await.unwrap();
935 assert_eq!(resp.status(), StatusCode::OK);
936 assert_eq!(response_body(resp).await, "ok");
937 }
938
939 #[tokio::test]
940 async fn middleware_rejects_missing_auth_header() {
941 let lookup = Arc::new(TestBridgeLookup::new("https://node.example.com"));
942 let app = test_app(lookup);
943
944 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
945
946 let resp = app.oneshot(req).await.unwrap();
947 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
948 let body = response_body(resp).await;
949 assert!(body.contains("BRIDGE_NOT_AUTHORIZED"));
950 }
951
952 #[tokio::test]
953 async fn middleware_rejects_expired_jwt() {
954 let signing_key = SigningKey::generate(&mut OsRng);
955 let did = test_did(&signing_key);
956 let mut claims = test_claims(&did);
957 claims.iat = current_time() - 7200;
958 claims.exp = current_time() - 120;
959 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
960
961 let mut lookup = TestBridgeLookup::new("https://node.example.com");
962 lookup
963 .did_docs
964 .push((did.clone(), test_did_document(&did, &signing_key)));
965 lookup.bridges.push(test_bridge(
966 "bridge-test-001",
967 &did,
968 "ctx-test-001",
969 BridgeStatus::Active,
970 ));
971 let lookup = Arc::new(lookup);
972
973 let app = test_app(lookup);
974 let req = Request::builder()
975 .uri("/test")
976 .header("Authorization", format!("Bearer {token}"))
977 .body(Body::empty())
978 .unwrap();
979
980 let resp = app.oneshot(req).await.unwrap();
981 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
982 let body = response_body(resp).await;
983 assert!(body.contains("BRIDGE_NOT_AUTHORIZED"));
984 }
985
986 #[tokio::test]
987 async fn middleware_rejects_wrong_key_jwt() {
988 let signing_key = SigningKey::generate(&mut OsRng);
989 let wrong_key = SigningKey::generate(&mut OsRng);
990 let did = test_did(&signing_key);
991 let claims = test_claims(&did);
992 let token = create_bridge_jwt(&claims, &wrong_key).unwrap();
993
994 let mut lookup = TestBridgeLookup::new("https://node.example.com");
995 lookup
996 .did_docs
997 .push((did.clone(), test_did_document(&did, &signing_key)));
998 lookup.bridges.push(test_bridge(
999 "bridge-test-001",
1000 &did,
1001 "ctx-test-001",
1002 BridgeStatus::Active,
1003 ));
1004 let lookup = Arc::new(lookup);
1005
1006 let app = test_app(lookup);
1007 let req = Request::builder()
1008 .uri("/test")
1009 .header("Authorization", format!("Bearer {token}"))
1010 .body(Body::empty())
1011 .unwrap();
1012
1013 let resp = app.oneshot(req).await.unwrap();
1014 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1015 let body = response_body(resp).await;
1016 assert!(body.contains("BRIDGE_NOT_AUTHORIZED"));
1017 }
1018
1019 #[tokio::test]
1020 async fn middleware_returns_403_for_suspended_bridge() {
1021 let signing_key = SigningKey::generate(&mut OsRng);
1022 let did = test_did(&signing_key);
1023 let claims = test_claims(&did);
1024 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
1025
1026 let mut lookup = TestBridgeLookup::new("https://node.example.com");
1027 lookup
1028 .did_docs
1029 .push((did.clone(), test_did_document(&did, &signing_key)));
1030 lookup.bridges.push(test_bridge(
1031 "bridge-test-001",
1032 &did,
1033 "ctx-test-001",
1034 BridgeStatus::Suspended,
1035 ));
1036 let lookup = Arc::new(lookup);
1037
1038 let app = test_app(lookup);
1039 let req = Request::builder()
1040 .uri("/test")
1041 .header("Authorization", format!("Bearer {token}"))
1042 .body(Body::empty())
1043 .unwrap();
1044
1045 let resp = app.oneshot(req).await.unwrap();
1046 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1047 let body = response_body(resp).await;
1048 assert!(body.contains("BRIDGE_SUSPENDED"));
1049 }
1050
1051 #[tokio::test]
1052 async fn middleware_rejects_revoked_bridge() {
1053 let signing_key = SigningKey::generate(&mut OsRng);
1054 let did = test_did(&signing_key);
1055 let claims = test_claims(&did);
1056 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
1057
1058 let mut lookup = TestBridgeLookup::new("https://node.example.com");
1059 lookup
1060 .did_docs
1061 .push((did.clone(), test_did_document(&did, &signing_key)));
1062 lookup.bridges.push(test_bridge(
1063 "bridge-test-001",
1064 &did,
1065 "ctx-test-001",
1066 BridgeStatus::Revoked,
1067 ));
1068 let lookup = Arc::new(lookup);
1069
1070 let app = test_app(lookup);
1071 let req = Request::builder()
1072 .uri("/test")
1073 .header("Authorization", format!("Bearer {token}"))
1074 .body(Body::empty())
1075 .unwrap();
1076
1077 let resp = app.oneshot(req).await.unwrap();
1078 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1079 let body = response_body(resp).await;
1080 assert!(body.contains("BRIDGE_NOT_AUTHORIZED"));
1081 }
1082
1083 #[tokio::test]
1084 async fn middleware_rejects_operator_did_mismatch() {
1085 let signing_key = SigningKey::generate(&mut OsRng);
1086 let did = test_did(&signing_key);
1087 let claims = test_claims(&did);
1088 let token = create_bridge_jwt(&claims, &signing_key).unwrap();
1089
1090 let mut lookup = TestBridgeLookup::new("https://node.example.com");
1091 lookup
1092 .did_docs
1093 .push((did.clone(), test_did_document(&did, &signing_key)));
1094 lookup.bridges.push(test_bridge(
1096 "bridge-test-001",
1097 "did:dht:z6MkDifferentOperator",
1098 "ctx-test-001",
1099 BridgeStatus::Active,
1100 ));
1101 let lookup = Arc::new(lookup);
1102
1103 let app = test_app(lookup);
1104 let req = Request::builder()
1105 .uri("/test")
1106 .header("Authorization", format!("Bearer {token}"))
1107 .body(Body::empty())
1108 .unwrap();
1109
1110 let resp = app.oneshot(req).await.unwrap();
1111 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1112 }
1113
1114 #[test]
1119 fn verify_valid_webhook_signature() {
1120 use ed25519_dalek::Signer;
1121
1122 let signing_key = SigningKey::generate(&mut OsRng);
1123 let pub_key = *signing_key.verifying_key().as_bytes();
1124 let body = b"webhook payload content";
1125
1126 let signature = signing_key.sign(body);
1127 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1128
1129 let mut lookup = TestBridgeLookup::new("https://node.example.com");
1130 lookup
1131 .webhook_keys
1132 .push(("platform-key-1".to_owned(), pub_key));
1133
1134 let result = verify_webhook_signature(&sig_b64, "platform-key-1", body, &lookup);
1135 assert!(result.is_ok());
1136 }
1137
1138 #[test]
1139 fn reject_invalid_webhook_signature() {
1140 use ed25519_dalek::Signer;
1141
1142 let signing_key = SigningKey::generate(&mut OsRng);
1143 let wrong_key = SigningKey::generate(&mut OsRng);
1144 let pub_key = *signing_key.verifying_key().as_bytes();
1145 let body = b"webhook payload content";
1146
1147 let signature = wrong_key.sign(body);
1149 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1150
1151 let mut lookup = TestBridgeLookup::new("https://node.example.com");
1152 lookup
1153 .webhook_keys
1154 .push(("platform-key-1".to_owned(), pub_key));
1155
1156 let result = verify_webhook_signature(&sig_b64, "platform-key-1", body, &lookup);
1157 assert!(result.is_err());
1158 assert!(
1159 result
1160 .unwrap_err()
1161 .contains("signature verification failed")
1162 );
1163 }
1164
1165 #[test]
1166 fn reject_unknown_webhook_key_id() {
1167 let body = b"webhook payload";
1168 let sig_b64 = URL_SAFE_NO_PAD.encode([0u8; 64]);
1169 let lookup = TestBridgeLookup::new("https://node.example.com");
1170
1171 let result = verify_webhook_signature(&sig_b64, "unknown-key", body, &lookup);
1172 assert!(result.is_err());
1173 assert!(result.unwrap_err().contains("unknown platform key ID"));
1174 }
1175
1176 #[test]
1177 fn reject_tampered_webhook_body() {
1178 use ed25519_dalek::Signer;
1179
1180 let signing_key = SigningKey::generate(&mut OsRng);
1181 let pub_key = *signing_key.verifying_key().as_bytes();
1182 let body = b"original payload";
1183 let tampered_body = b"tampered payload";
1184
1185 let signature = signing_key.sign(body);
1186 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1187
1188 let mut lookup = TestBridgeLookup::new("https://node.example.com");
1189 lookup
1190 .webhook_keys
1191 .push(("platform-key-1".to_owned(), pub_key));
1192
1193 let result = verify_webhook_signature(&sig_b64, "platform-key-1", tampered_body, &lookup);
1194 assert!(result.is_err());
1195 }
1196}