1use base64::Engine;
2use chrono::{DateTime, Utc};
3use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
4use hmac::{Hmac, KeyInit, Mac};
5use lean_ctx_protocol::DataClassification;
6use serde::{Deserialize, Serialize};
7use sha2::Sha256;
8use std::collections::HashSet;
9use std::net::IpAddr;
10
11use super::super::a2a_transport::TransportContentType;
12
13pub const RELAY_RECORD_VERSION: u8 = 1;
14pub const RELAY_PEER_TABLE_VERSION: u8 = 1;
15pub const RELAY_METADATA_KEY: &str = "leanctx.relay.v1";
16pub const RELAY_PEER_HEADER: &str = "x-leanctx-peer-id";
17pub const RELAY_MAX_HOPS: usize = 16;
18pub const RELAY_MAX_PEERS: usize = 32;
19pub const RELAY_MAX_RECORD_BYTES: usize = 32 * 1024;
20pub const RELAY_MAX_PAYLOAD_BYTES: usize = 2 * 1024 * 1024;
21pub const RELAY_MAX_DEADLINE_SECS: i64 = 24 * 60 * 60;
22pub const RELAY_MAX_RETRIES: u8 = 5;
23pub const RELAY_MAX_RETRY_DELAY_MS: u64 = 30_000;
24pub const RELAY_MAX_ID_BYTES: usize = 128;
25
26type HmacSha256 = Hmac<Sha256>;
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(deny_unknown_fields)]
30pub struct RelayHop {
31 pub agent_id: String,
32 pub received_at: DateTime<Utc>,
33 pub forwarded_at: Option<DateTime<Utc>>,
34 pub processing_ms: Option<u64>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(deny_unknown_fields)]
39pub struct RelayChain {
40 pub hops: Vec<RelayHop>,
41 pub max_hops: usize,
42}
43
44impl RelayChain {
45 pub fn new(max_hops: usize) -> Self {
46 Self {
47 hops: Vec::new(),
48 max_hops,
49 }
50 }
51
52 pub fn add_hop(&mut self, hop: RelayHop) -> Result<(), RelayError> {
53 if self.max_hops == 0 || self.max_hops > RELAY_MAX_HOPS || self.hops.len() >= self.max_hops
54 {
55 return Err(RelayError::MaxHopsExceeded(self.max_hops));
56 }
57 if hop.agent_id.is_empty() || hop.agent_id.len() > RELAY_MAX_ID_BYTES {
58 return Err(RelayError::Malformed("relay hop identity".into()));
59 }
60 if self.contains_agent(&hop.agent_id) {
61 return Err(RelayError::CycleDetected(hop.agent_id));
62 }
63 if hop
64 .forwarded_at
65 .is_some_and(|forwarded| forwarded < hop.received_at)
66 {
67 return Err(RelayError::Malformed("relay hop timestamps".into()));
68 }
69 self.hops.push(hop);
70 Ok(())
71 }
72
73 pub fn add_peer_hop(
74 &mut self,
75 agent_id: impl Into<String>,
76 now: DateTime<Utc>,
77 ) -> Result<(), RelayError> {
78 self.add_hop(RelayHop {
79 agent_id: agent_id.into(),
80 received_at: now,
81 forwarded_at: Some(now),
82 processing_ms: Some(0),
83 })
84 }
85
86 pub fn depth(&self) -> usize {
87 self.hops.len()
88 }
89
90 pub fn total_latency_ms(&self) -> u64 {
91 self.hops
92 .iter()
93 .filter_map(|hop| hop.processing_ms)
94 .fold(0, u64::saturating_add)
95 }
96
97 pub fn contains_agent(&self, agent_id: &str) -> bool {
98 self.hops.iter().any(|hop| hop.agent_id == agent_id)
99 }
100
101 pub fn origin(&self) -> Option<&str> {
102 self.hops.first().map(|hop| hop.agent_id.as_str())
103 }
104
105 pub fn validate(&self) -> Result<(), RelayError> {
106 if self.max_hops == 0
107 || self.max_hops > RELAY_MAX_HOPS
108 || self.hops.is_empty()
109 || self.hops.len() > self.max_hops
110 {
111 return Err(RelayError::MaxHopsExceeded(self.max_hops));
112 }
113 let mut identities = HashSet::new();
114 for hop in &self.hops {
115 if hop.agent_id.is_empty() || hop.agent_id.len() > RELAY_MAX_ID_BYTES {
116 return Err(RelayError::Malformed("relay hop identity".into()));
117 }
118 if hop
119 .forwarded_at
120 .is_some_and(|forwarded| forwarded < hop.received_at)
121 || !identities.insert(&hop.agent_id)
122 {
123 return Err(RelayError::CycleDetected(hop.agent_id.clone()));
124 }
125 }
126 Ok(())
127 }
128}
129
130impl Default for RelayChain {
131 fn default() -> Self {
132 Self::new(5)
133 }
134}
135
136#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
137struct RelayOriginView<'a> {
138 schema_version: u8,
139 payload_sha256: &'a str,
140 delivery_id: &'a str,
141 origin: &'a str,
142 final_recipient: &'a str,
143 tenant_id: &'a str,
144 project_id: &'a str,
145 content_type: &'a TransportContentType,
146 classification: &'a DataClassification,
147 expires_at: DateTime<Utc>,
148 max_hops: usize,
149 origin_hop: &'a RelayHop,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(deny_unknown_fields)]
154pub struct RelayRecordV1 {
155 pub schema_version: u8,
156 pub payload_sha256: String,
157 pub delivery_id: String,
158 pub origin: String,
159 pub final_recipient: String,
160 pub current_peer: String,
161 pub next_peer: String,
162 pub tenant_id: String,
163 pub project_id: String,
164 pub content_type: TransportContentType,
165 pub classification: DataClassification,
166 pub expires_at: DateTime<Utc>,
167 pub relay_chain: RelayChain,
168 pub origin_signature: String,
169 pub hop_signature: String,
170}
171
172impl RelayRecordV1 {
173 #[allow(clippy::too_many_arguments)]
174 pub fn new_signed(
175 delivery_id: impl Into<String>,
176 origin: impl Into<String>,
177 final_recipient: impl Into<String>,
178 tenant_id: impl Into<String>,
179 project_id: impl Into<String>,
180 content_type: TransportContentType,
181 classification: DataClassification,
182 expires_at: DateTime<Utc>,
183 max_hops: usize,
184 now: DateTime<Utc>,
185 origin_key: &SigningKey,
186 channel_key: &str,
187 payload: &[u8],
188 ) -> Result<Self, RelayError> {
189 let origin = origin.into();
190 let mut relay_chain = RelayChain::new(max_hops);
191 relay_chain.add_peer_hop(origin.clone(), now)?;
192 let mut record = Self {
193 schema_version: RELAY_RECORD_VERSION,
194 payload_sha256: Self::payload_digest(payload),
195 delivery_id: delivery_id.into(),
196 origin: origin.clone(),
197 final_recipient: final_recipient.into(),
198 current_peer: origin,
199 next_peer: String::new(),
200 tenant_id: tenant_id.into(),
201 project_id: project_id.into(),
202 content_type,
203 classification,
204 expires_at,
205 relay_chain,
206 origin_signature: String::new(),
207 hop_signature: String::new(),
208 };
209 record.next_peer = record.final_recipient.clone();
210 record.validate_shape()?;
211 record.validate_deadline(now)?;
212 record.origin_signature = record.sign_origin(origin_key)?;
213 record.hop_signature = record.sign_hop(channel_key)?;
214 Ok(record)
215 }
216
217 pub fn validate_shape(&self) -> Result<(), RelayError> {
218 if self.payload_sha256.len() != 64
219 || !self
220 .payload_sha256
221 .bytes()
222 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
223 {
224 return Err(RelayError::Malformed("payload digest".into()));
225 }
226 if self.schema_version != RELAY_RECORD_VERSION {
227 return Err(RelayError::Malformed("relay record version".into()));
228 }
229 for (name, value) in [
230 ("delivery_id", self.delivery_id.as_str()),
231 ("origin", self.origin.as_str()),
232 ("final_recipient", self.final_recipient.as_str()),
233 ("current_peer", self.current_peer.as_str()),
234 ("next_peer", self.next_peer.as_str()),
235 ("tenant_id", self.tenant_id.as_str()),
236 ("project_id", self.project_id.as_str()),
237 ] {
238 if value.is_empty() || value.len() > RELAY_MAX_ID_BYTES {
239 return Err(RelayError::Malformed(name.into()));
240 }
241 }
242 if self.origin_signature.len() > RELAY_MAX_ID_BYTES * 2
243 || self.hop_signature.len() > RELAY_MAX_ID_BYTES * 2
244 {
245 return Err(RelayError::Malformed("relay signature".into()));
246 }
247 self.relay_chain.validate()?;
248 if self
249 .relay_chain
250 .hops
251 .last()
252 .map(|hop| hop.agent_id.as_str())
253 != Some(self.current_peer.as_str())
254 || self.relay_chain.origin() != Some(self.origin.as_str())
255 {
256 return Err(RelayError::Malformed("relay chain identity".into()));
257 }
258 Ok(())
259 }
260
261 fn validate_deadline(&self, now: DateTime<Utc>) -> Result<(), RelayError> {
262 if self.expires_at <= now {
263 return Err(RelayError::DeadlineExceeded);
264 }
265 if self.expires_at.signed_duration_since(now).num_seconds() > RELAY_MAX_DEADLINE_SECS {
266 return Err(RelayError::Malformed("relay deadline exceeds cap".into()));
267 }
268 Ok(())
269 }
270
271 pub fn validate_at(
272 &self,
273 now: DateTime<Utc>,
274 expected_current: &str,
275 expected_next: Option<&str>,
276 max_hops: usize,
277 ) -> Result<(), RelayError> {
278 self.validate_shape()?;
279 self.validate_deadline(now)?;
280 if self.current_peer != expected_current
281 || expected_next
282 .map(|peer| self.next_peer != peer)
283 .unwrap_or(false)
284 {
285 return Err(RelayError::RouteMismatch);
286 }
287 if self.relay_chain.max_hops > max_hops || self.relay_chain.depth() > max_hops {
288 return Err(RelayError::MaxHopsExceeded(max_hops));
289 }
290 if self.origin_signature.is_empty() || self.hop_signature.is_empty() {
291 return Err(RelayError::InvalidSignature);
292 }
293 Ok(())
294 }
295
296 fn origin_view(&self) -> Result<RelayOriginView<'_>, RelayError> {
297 Ok(RelayOriginView {
298 schema_version: self.schema_version,
299 payload_sha256: &self.payload_sha256,
300 delivery_id: &self.delivery_id,
301 origin: &self.origin,
302 final_recipient: &self.final_recipient,
303 tenant_id: &self.tenant_id,
304 project_id: &self.project_id,
305 content_type: &self.content_type,
306 classification: &self.classification,
307 expires_at: self.expires_at,
308 max_hops: self.relay_chain.max_hops,
309 origin_hop: self
310 .relay_chain
311 .hops
312 .first()
313 .ok_or_else(|| RelayError::Malformed("relay origin hop".into()))?,
314 })
315 }
316
317 fn origin_bytes(&self) -> Result<Vec<u8>, RelayError> {
318 serde_json::to_vec(&("leanctx.relay.origin.v1", self.origin_view()?))
319 .map_err(|e| RelayError::Malformed(e.to_string()))
320 }
321
322 #[cfg(feature = "http-server")]
323 pub fn origin_fingerprint(&self) -> Result<String, RelayError> {
324 Ok(Self::payload_digest(&self.origin_bytes()?))
325 }
326
327 fn payload_digest(payload: &[u8]) -> String {
328 use sha2::Digest;
329 hex::encode(Sha256::digest(payload))
330 }
331
332 pub fn verify_payload(&self, payload: &[u8]) -> Result<(), RelayError> {
333 if self.payload_sha256 != Self::payload_digest(payload) {
334 return Err(RelayError::InvalidSignature);
335 }
336 Ok(())
337 }
338
339 fn hop_bytes(&self) -> Result<Vec<u8>, RelayError> {
340 let mut unsigned = self.clone();
341 unsigned.hop_signature.clear();
342 serde_json::to_vec(&("leanctx.relay.hop.v1", unsigned))
343 .map_err(|e| RelayError::Malformed(e.to_string()))
344 }
345
346 fn sign(key: &str, bytes: &[u8]) -> Result<String, RelayError> {
347 if key.is_empty() {
348 return Err(RelayError::InvalidCredential);
349 }
350 let mut mac = HmacSha256::new_from_slice(key.as_bytes())
351 .map_err(|_| RelayError::InvalidCredential)?;
352 mac.update(bytes);
353 Ok(hex::encode(mac.finalize().into_bytes()))
354 }
355
356 fn sign_origin(&self, key: &SigningKey) -> Result<String, RelayError> {
357 Ok(hex::encode(key.sign(&self.origin_bytes()?).to_bytes()))
358 }
359
360 fn sign_hop(&self, key: &str) -> Result<String, RelayError> {
361 Self::sign(key, &self.hop_bytes()?)
362 }
363
364 pub fn verify_origin(&self, key: &str) -> Result<(), RelayError> {
365 let key = decode_origin_key(key)?;
366 if self.origin_signature.len() != 128 {
367 return Err(RelayError::InvalidSignature);
368 }
369 let bytes =
370 hex::decode(&self.origin_signature).map_err(|_| RelayError::InvalidSignature)?;
371 let signature = Signature::from_slice(&bytes).map_err(|_| RelayError::InvalidSignature)?;
372 key.verify_strict(&self.origin_bytes()?, &signature)
373 .map_err(|_| RelayError::InvalidSignature)
374 }
375
376 pub fn verify_hop(&self, key: &str) -> Result<(), RelayError> {
377 verify_signature(&self.hop_signature, &self.sign_hop(key)?)
378 }
379
380 pub fn route_from_origin(
382 &mut self,
383 next_peer: impl Into<String>,
384 now: DateTime<Utc>,
385 origin_public_key: &str,
386 channel_key: &str,
387 ) -> Result<(), RelayError> {
388 self.validate_at(now, &self.origin, None, self.relay_chain.max_hops)?;
389 self.verify_origin(origin_public_key)?;
390 if self.relay_chain.depth() != 1 {
391 return Err(RelayError::RouteMismatch);
392 }
393 let mut routed = self.clone();
394 routed.next_peer = next_peer.into();
395 if routed.relay_chain.contains_agent(&routed.next_peer) {
396 return Err(RelayError::CycleDetected(routed.next_peer));
397 }
398 routed.validate_shape()?;
399 routed.hop_signature = routed.sign_hop(channel_key)?;
400 *self = routed;
401 Ok(())
402 }
403
404 pub fn forward_from(
405 &mut self,
406 local_peer: impl Into<String>,
407 next_peer: impl Into<String>,
408 now: DateTime<Utc>,
409 hop_key: &str,
410 ) -> Result<(), RelayError> {
411 self.validate_at(
412 now,
413 &self.current_peer.clone(),
414 None,
415 self.relay_chain.max_hops,
416 )?;
417 let local_peer = local_peer.into();
418 if local_peer != self.next_peer {
419 return Err(RelayError::RouteMismatch);
420 }
421 let mut forwarded = self.clone();
422 forwarded
423 .relay_chain
424 .add_peer_hop(local_peer.clone(), now)?;
425 forwarded.current_peer = local_peer;
426 forwarded.next_peer = next_peer.into();
427 if forwarded.relay_chain.contains_agent(&forwarded.next_peer) {
428 return Err(RelayError::CycleDetected(forwarded.next_peer));
429 }
430 forwarded.validate_shape()?;
431 forwarded.hop_signature = forwarded.sign_hop(hop_key)?;
432 *self = forwarded;
433 Ok(())
434 }
435
436 pub fn encode_metadata(&self) -> Result<String, RelayError> {
437 self.validate_shape()?;
438 let bytes = serde_json::to_vec(self).map_err(|e| RelayError::Malformed(e.to_string()))?;
439 if bytes.len() > RELAY_MAX_RECORD_BYTES {
440 return Err(RelayError::Oversize);
441 }
442 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
443 }
444
445 pub fn decode_metadata(value: &str) -> Result<Self, RelayError> {
446 if value.is_empty() || value.len() > RELAY_MAX_RECORD_BYTES * 2 {
447 return Err(RelayError::Oversize);
448 }
449 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
450 .decode(value)
451 .map_err(|_| RelayError::Malformed("relay metadata encoding".into()))?;
452 if bytes.len() > RELAY_MAX_RECORD_BYTES {
453 return Err(RelayError::Oversize);
454 }
455 let record: Self = serde_json::from_slice(&bytes)
456 .map_err(|e| RelayError::Malformed(format!("relay metadata: {e}")))?;
457 record.validate_shape()?;
458 if record.encode_metadata()? != value {
459 return Err(RelayError::Malformed("non-canonical relay metadata".into()));
460 }
461 Ok(record)
462 }
463}
464
465fn verify_signature(actual: &str, expected: &str) -> Result<(), RelayError> {
466 let actual = hex::decode(actual).map_err(|_| RelayError::InvalidSignature)?;
467 let expected = hex::decode(expected).map_err(|_| RelayError::InvalidSignature)?;
468 if actual.len() != expected.len() || !constant_time_eq(&actual, &expected) {
469 return Err(RelayError::InvalidSignature);
470 }
471 Ok(())
472}
473
474fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
475 if left.len() != right.len() {
476 return false;
477 }
478 let mut difference = 0u8;
479 for (&left, &right) in left.iter().zip(right) {
480 difference |= left ^ right;
481 }
482 difference == 0
483}
484
485fn decode_origin_key(raw: &str) -> Result<VerifyingKey, RelayError> {
486 if raw.len() != 64 {
487 return Err(RelayError::InvalidCredential);
488 }
489 let bytes: [u8; 32] = hex::decode(raw)
490 .map_err(|_| RelayError::InvalidCredential)?
491 .try_into()
492 .map_err(|_| RelayError::InvalidCredential)?;
493 let key = VerifyingKey::from_bytes(&bytes).map_err(|_| RelayError::InvalidCredential)?;
494 if key.is_weak() {
495 return Err(RelayError::InvalidCredential);
496 }
497 Ok(key)
498}
499
500#[cfg(any(test, feature = "test-support"))]
501pub fn test_origin_key(label: &str) -> SigningKey {
502 use sha2::Digest;
503 let seed: [u8; 32] = Sha256::digest(format!("test-only-relay-origin:{label}")).into();
505 SigningKey::from_bytes(&seed)
506}
507
508#[cfg(any(test, feature = "test-support"))]
509pub fn test_origin_public_key(label: &str) -> String {
510 hex::encode(test_origin_key(label).verifying_key().to_bytes())
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
514#[serde(deny_unknown_fields)]
515pub struct RelayPeerConfigV1 {
516 pub schema_version: u8,
517 pub peer_id: String,
518 pub endpoint_url: String,
519 pub bearer_token: String,
520 pub channel_key: String,
521 pub origin_public_key: String,
523 pub recipient_id: String,
524 pub allowed_tenant_ids: Vec<String>,
525 pub allowed_project_ids: Vec<String>,
526 pub allowed_content_types: Vec<TransportContentType>,
527 pub allowed_classifications: Vec<DataClassification>,
528 pub max_hops: usize,
529 pub max_payload_bytes: usize,
530 pub retry_count: u8,
531 pub retry_delay_ms: u64,
532}
533
534impl RelayPeerConfigV1 {
535 pub fn validate(&self, allow_loopback_http: bool) -> Result<(), RelayError> {
536 decode_origin_key(&self.origin_public_key)?;
537 if self.schema_version != RELAY_PEER_TABLE_VERSION
538 || self.peer_id.is_empty()
539 || self.peer_id == "legacy"
540 || self.peer_id.len() > RELAY_MAX_ID_BYTES
541 || self.recipient_id.is_empty()
542 || self.recipient_id.len() > RELAY_MAX_ID_BYTES
543 || self.bearer_token.is_empty()
544 || self.channel_key.is_empty()
545 || self.bearer_token == self.channel_key
546 {
547 return Err(RelayError::InvalidPeer(self.peer_id.clone()));
548 }
549 if self.allowed_tenant_ids.is_empty()
550 || self.allowed_project_ids.is_empty()
551 || self.allowed_content_types.is_empty()
552 || self.allowed_classifications.is_empty()
553 {
554 return Err(RelayError::ScopeViolation);
555 }
556 ensure_unique(&self.allowed_tenant_ids)?;
557 ensure_unique(&self.allowed_project_ids)?;
558 if self.max_hops == 0 || self.max_hops > RELAY_MAX_HOPS {
559 return Err(RelayError::MaxHopsExceeded(self.max_hops));
560 }
561 if self.max_payload_bytes == 0 || self.max_payload_bytes > RELAY_MAX_PAYLOAD_BYTES {
562 return Err(RelayError::Oversize);
563 }
564 if self.retry_count > RELAY_MAX_RETRIES || self.retry_delay_ms > RELAY_MAX_RETRY_DELAY_MS {
565 return Err(RelayError::RetryCap);
566 }
567 if self
568 .allowed_content_types
569 .iter()
570 .any(|kind| matches!(kind, TransportContentType::A2AMessage))
571 {
572 return Err(RelayError::ScopeViolation);
573 }
574 let url = reqwest::Url::parse(&self.endpoint_url)
575 .map_err(|_| RelayError::InvalidPeer(self.peer_id.clone()))?;
576 let loopback = url.host_str().map(is_loopback_host).unwrap_or(false);
577 if url.host_str().is_none()
578 || !url.username().is_empty()
579 || url.password().is_some()
580 || !(url.scheme() == "https"
581 || (allow_loopback_http && loopback && url.scheme() == "http"))
582 {
583 return Err(RelayError::InvalidPeer(self.peer_id.clone()));
584 }
585 Ok(())
586 }
587
588 pub fn allows(&self, record: &RelayRecordV1, payload_bytes: usize) -> bool {
589 payload_bytes <= self.max_payload_bytes
590 && self
591 .allowed_tenant_ids
592 .iter()
593 .any(|id| id == &record.tenant_id)
594 && self
595 .allowed_project_ids
596 .iter()
597 .any(|id| id == &record.project_id)
598 && self
599 .allowed_content_types
600 .iter()
601 .any(|kind| kind == &record.content_type)
602 && self
603 .allowed_classifications
604 .iter()
605 .any(|kind| kind == &record.classification)
606 && record.relay_chain.depth() <= self.max_hops
607 }
608}
609
610fn ensure_unique(values: &[String]) -> Result<(), RelayError> {
611 let mut seen = HashSet::new();
612 if values
613 .iter()
614 .any(|value| value.is_empty() || !seen.insert(value))
615 {
616 return Err(RelayError::ScopeViolation);
617 }
618 Ok(())
619}
620
621fn is_loopback_host(host: &str) -> bool {
622 host.eq_ignore_ascii_case("localhost")
623 || host
624 .parse::<IpAddr>()
625 .map(|ip| ip.is_loopback())
626 .unwrap_or(false)
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
630#[serde(deny_unknown_fields)]
631pub struct RelayPeerTableV1 {
632 pub schema_version: u8,
633 pub peers: Vec<RelayPeerConfigV1>,
634}
635
636impl Default for RelayPeerTableV1 {
637 fn default() -> Self {
638 Self {
639 schema_version: RELAY_PEER_TABLE_VERSION,
640 peers: Vec::new(),
641 }
642 }
643}
644
645impl RelayPeerTableV1 {
646 pub fn validate(&self, allow_loopback_http: bool) -> Result<(), RelayError> {
647 if self.schema_version != RELAY_PEER_TABLE_VERSION || self.peers.len() > RELAY_MAX_PEERS {
648 return Err(RelayError::InvalidPeer("peer table".into()));
649 }
650 let mut ids = HashSet::new();
651 for peer in &self.peers {
652 peer.validate(allow_loopback_http)?;
653 if !ids.insert(&peer.peer_id) {
654 return Err(RelayError::InvalidPeer(peer.peer_id.clone()));
655 }
656 }
657 Ok(())
658 }
659
660 pub fn validate_for_tests(&self) -> Result<(), RelayError> {
661 self.validate(true)
662 }
663
664 pub fn from_json(value: &str, allow_loopback_http: bool) -> Result<Self, RelayError> {
665 let table: Self = serde_json::from_str(value)
666 .map_err(|e| RelayError::Malformed(format!("peer table: {e}")))?;
667 table.validate(allow_loopback_http)?;
668 Ok(table)
669 }
670
671 pub fn peer(&self, peer_id: &str) -> Option<&RelayPeerConfigV1> {
672 self.peers.iter().find(|peer| peer.peer_id == peer_id)
673 }
674
675 pub fn route_for_recipient(
676 &self,
677 recipient_id: &str,
678 ) -> Result<&RelayPeerConfigV1, RelayError> {
679 let mut routes = self
680 .peers
681 .iter()
682 .filter(|peer| peer.recipient_id == recipient_id);
683 let first = routes
684 .next()
685 .ok_or_else(|| RelayError::UnknownRoute(recipient_id.into()))?;
686 if routes.next().is_some() {
687 return Err(RelayError::AmbiguousRoute(recipient_id.into()));
688 }
689 Ok(first)
690 }
691
692 pub fn reject_shared_secret(&self) -> Result<(), RelayError> {
693 for (index, left) in self.peers.iter().enumerate() {
694 for right in self.peers.iter().skip(index + 1) {
695 if left.channel_key == right.channel_key || left.bearer_token == right.bearer_token
696 {
697 return Err(RelayError::InvalidCredential);
698 }
699 }
700 }
701 Ok(())
702 }
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, thiserror::Error)]
706pub enum RelayError {
707 #[error("relay chain exceeded maximum of {0} hops")]
708 MaxHopsExceeded(usize),
709 #[error("relay cycle detected at agent {0}")]
710 CycleDetected(String),
711 #[error("malformed relay data: {0}")]
712 Malformed(String),
713 #[error("relay data exceeds its cap")]
714 Oversize,
715 #[error("relay deadline expired")]
716 DeadlineExceeded,
717 #[error("relay route mismatch")]
718 RouteMismatch,
719 #[error("relay scope or classification is not allowed")]
720 ScopeViolation,
721 #[error("unknown relay route: {0}")]
722 UnknownRoute(String),
723 #[error("ambiguous relay route: {0}")]
724 AmbiguousRoute(String),
725 #[error("invalid relay peer: {0}")]
726 InvalidPeer(String),
727 #[error("invalid relay signature")]
728 InvalidSignature,
729 #[error("invalid relay credential")]
730 InvalidCredential,
731 #[error("relay retry cap exceeded")]
732 RetryCap,
733}
734
735#[cfg(test)]
736mod tests {
737 use chrono::{Duration, TimeZone, Utc};
738 use lean_ctx_protocol::DataClassification;
739
740 use super::{
741 RelayChain, RelayError, RelayHop, RelayRecordV1, test_origin_key, test_origin_public_key,
742 };
743 use crate::core::a2a_transport::TransportContentType;
744
745 fn hop(agent_id: &str, processing_ms: Option<u64>) -> RelayHop {
746 RelayHop {
747 agent_id: agent_id.to_owned(),
748 received_at: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
749 forwarded_at: None,
750 processing_ms,
751 }
752 }
753
754 #[test]
755 fn unforwarded_hop_is_valid_but_time_reversal_is_rejected() {
756 let mut pending = hop("pending", None);
757 pending.forwarded_at = None;
758 let mut chain = RelayChain::default();
759 chain.add_hop(pending.clone()).unwrap();
760 chain.validate().unwrap();
761
762 pending.agent_id = "backwards".into();
763 pending.forwarded_at = Some(pending.received_at - Duration::seconds(1));
764 assert!(chain.add_hop(pending.clone()).is_err());
765 chain.hops.push(pending);
766 assert!(chain.validate().is_err());
767 }
768
769 #[test]
770 fn relay_peer_cannot_select_the_legacy_retry_namespace() {
771 let mut peer = super::RelayPeerConfigV1 {
772 schema_version: 1,
773 peer_id: "peer".into(),
774 endpoint_url: "https://peer.example".into(),
775 bearer_token: "bearer".into(),
776 channel_key: "channel-key".into(),
777 origin_public_key: test_origin_public_key("peer"),
778 recipient_id: "recipient".into(),
779 allowed_tenant_ids: vec!["tenant".into()],
780 allowed_project_ids: vec!["project".into()],
781 allowed_content_types: vec![TransportContentType::EvidenceBundle],
782 allowed_classifications: vec![DataClassification::Internal],
783 max_hops: 4,
784 max_payload_bytes: 1024,
785 retry_count: 0,
786 retry_delay_ms: 0,
787 };
788 peer.validate(false).unwrap();
789 peer.peer_id = "legacy".into();
790 assert_eq!(
791 peer.validate(false),
792 Err(RelayError::InvalidPeer("legacy".into()))
793 );
794 }
795
796 #[test]
797 fn adds_hop_and_reports_chain_metadata() {
798 let mut chain = RelayChain::new(3);
799 chain.add_hop(hop("origin-agent", Some(4))).unwrap();
800 assert_eq!(chain.depth(), 1);
801 assert!(chain.contains_agent("origin-agent"));
802 assert_eq!(chain.origin(), Some("origin-agent"));
803 }
804
805 #[test]
806 fn rejects_cycle() {
807 let mut chain = RelayChain::new(3);
808 chain.add_hop(hop("relay-agent", None)).unwrap();
809 let error = chain.add_hop(hop("relay-agent", Some(1))).unwrap_err();
810 assert_eq!(error, RelayError::CycleDetected("relay-agent".to_owned()));
811 }
812
813 #[test]
814 fn rejects_hop_beyond_maximum() {
815 let mut chain = RelayChain::new(1);
816 chain.add_hop(hop("origin-agent", None)).unwrap();
817 assert_eq!(
818 chain.add_hop(hop("next-agent", None)),
819 Err(RelayError::MaxHopsExceeded(1))
820 );
821 }
822
823 #[test]
824 fn sums_available_processing_latency() {
825 let mut chain = RelayChain::new(3);
826 chain.add_hop(hop("origin-agent", Some(12))).unwrap();
827 chain.add_hop(hop("relay-agent", None)).unwrap();
828 chain.add_hop(hop("recipient-agent", Some(8))).unwrap();
829 assert_eq!(chain.total_latency_ms(), 20);
830 }
831
832 #[test]
833 fn default_chain_allows_five_hops() {
834 let mut chain = RelayChain::default();
835 for index in 0..5 {
836 chain
837 .add_hop(hop(&format!("agent-{index}"), Some(u64::MAX)))
838 .unwrap();
839 }
840 assert_eq!(chain.depth(), 5);
841 assert_eq!(chain.total_latency_ms(), u64::MAX);
842 assert_eq!(
843 chain.add_hop(hop("sixth-agent", None)),
844 Err(RelayError::MaxHopsExceeded(5))
845 );
846 }
847
848 #[test]
849 fn signed_record_has_canonical_reserved_wire_value_and_forwarding_changes_route_only() {
850 let now = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
851 let mut record = RelayRecordV1::new_signed(
852 "delivery-1",
853 "origin",
854 "recipient",
855 "tenant",
856 "project",
857 TransportContentType::A2ATask,
858 DataClassification::Internal,
859 now + Duration::hours(1),
860 4,
861 now,
862 &test_origin_key("origin"),
863 "origin-key",
864 b"task-payload",
865 )
866 .unwrap();
867 record
868 .route_from_origin(
869 "relay-a",
870 now,
871 &test_origin_public_key("origin"),
872 "inbound-key",
873 )
874 .unwrap();
875 assert!(
876 record
877 .verify_origin(&test_origin_public_key("origin"))
878 .is_ok()
879 );
880 assert!(record.verify_hop("inbound-key").is_ok());
881 let unchanged = record.encode_metadata().unwrap();
882 assert_eq!(
883 record.forward_from("unaddressed-peer", "recipient", now, "relay-key"),
884 Err(RelayError::RouteMismatch)
885 );
886 assert_eq!(record.encode_metadata().unwrap(), unchanged);
887 for invalid_next in ["", "origin", "relay-a"] {
888 assert!(
889 record
890 .forward_from("relay-a", invalid_next, now, "relay-key")
891 .is_err()
892 );
893 assert_eq!(record.encode_metadata().unwrap(), unchanged);
894 }
895 let encoded = record.encode_metadata().unwrap();
896 let mut forwarded = RelayRecordV1::decode_metadata(&encoded).unwrap();
897 forwarded
898 .forward_from(
899 "relay-a",
900 "recipient",
901 now + Duration::seconds(1),
902 "relay-key",
903 )
904 .unwrap();
905 assert_eq!(forwarded.delivery_id, "delivery-1");
906 assert!(
907 forwarded
908 .verify_origin(&test_origin_public_key("origin"))
909 .is_ok()
910 );
911 assert!(forwarded.verify_payload(b"task-payload").is_ok());
912 assert!(forwarded.verify_payload(b"substituted-payload").is_err());
913 let mut substituted = forwarded.clone();
914 substituted.payload_sha256 = RelayRecordV1::payload_digest(b"substituted-payload");
915 assert!(
916 substituted
917 .verify_origin(&test_origin_public_key("origin"))
918 .is_err()
919 );
920 substituted.origin_signature = substituted
923 .sign_origin(&test_origin_key("relay-a"))
924 .unwrap();
925 substituted.hop_signature = substituted.sign_hop("relay-key").unwrap();
926 assert!(substituted.verify_hop("relay-key").is_ok());
927 assert!(substituted.verify_payload(b"substituted-payload").is_ok());
928 assert_eq!(
929 substituted.verify_origin(&test_origin_public_key("origin")),
930 Err(RelayError::InvalidSignature)
931 );
932 assert!(forwarded.verify_hop("relay-key").is_ok());
933 assert!(RelayRecordV1::decode_metadata(&format!("{encoded} ")).is_err());
934 }
935
936 #[test]
937 fn relay_origin_public_keys_reject_wrong_length_non_hex_and_weak_points() {
938 for invalid in [
939 String::new(),
940 "a".repeat(62),
941 "a".repeat(66),
942 "z".repeat(64),
943 "0".repeat(64),
944 ] {
945 assert!(super::decode_origin_key(&invalid).is_err());
946 }
947 assert!(super::decode_origin_key(&test_origin_public_key("origin")).is_ok());
948 }
949}