Skip to main content

lean_ctx/core/a2a/
relay.rs

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    /// Select the first transport hop without changing the signed final recipient.
381    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    // Deterministic fixture keys only; production must supply an actual seed.
504    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    /// Public Ed25519 key only. Intermediate peers never receive origin seeds.
522    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        // An authenticated forwarding peer can replace its hop MAC, but cannot
921        // impersonate the origin using its own independent signing key.
922        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}