Skip to main content

appcore_peer_rpc/
validation.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: validation.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/22 15:41:18 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11use super::*;
12
13/// Validation limits and local identity expected by the peer RPC host.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct PeerRpcValidationConfig {
16    /// Tenant accepted by this host.
17    pub local_tenant_id: TenantId,
18    /// Cluster accepted by this host.
19    pub local_cluster_id: ClusterId,
20    /// Core identity targeted by incoming envelopes.
21    pub local_core_id: CoreId,
22    /// Maximum decoded application payload size.
23    pub max_payload_bytes: usize,
24    /// Maximum tolerated clock skew and replay window.
25    pub nonce_window_ms: u64,
26}
27
28/// Stateful validator for peer identity, protocol, expiry, integrity, and replay.
29#[derive(Debug, Clone)]
30pub struct PeerRpcValidator {
31    config: PeerRpcValidationConfig,
32    local_protocol_version: ProtocolVersion,
33    nonce_store: Arc<dyn crate::PeerNonceStore>,
34}
35impl PeerRpcValidator {
36    /// Creates a validator using the default protocol version.
37    pub fn new(config: PeerRpcValidationConfig) -> Self {
38        Self {
39            config,
40            local_protocol_version: ProtocolVersion::default(),
41            nonce_store: Arc::new(crate::InMemoryPeerNonceStore::default()),
42        }
43    }
44
45    /// Sets the protocol version accepted by this host.
46    pub fn with_protocol_version(mut self, protocol_version: ProtocolVersion) -> Self {
47        self.local_protocol_version = protocol_version;
48        self
49    }
50
51    /// Replaces process-local replay tracking with a deployment-selected store.
52    pub fn with_nonce_store(mut self, nonce_store: Arc<dyn crate::PeerNonceStore>) -> Self {
53        self.nonce_store = nonce_store;
54        self
55    }
56
57    pub(crate) fn max_envelope_bytes(&self) -> usize {
58        self.config
59            .max_payload_bytes
60            .saturating_mul(4)
61            .saturating_add(MAX_ENVELOPE_OVERHEAD_BYTES)
62    }
63
64    /// Validates an envelope and records its nonce to prevent replay.
65    pub fn validate(&self, envelope: &PeerRpcEnvelope, now_ms: u64) -> Result<(), PeerRpcError> {
66        validate_envelope_identifiers(envelope)?;
67        if envelope.payload.len() > self.config.max_payload_bytes {
68            return Err(PeerRpcError::PayloadTooLarge);
69        }
70        if envelope.tenant_id != self.config.local_tenant_id {
71            return Err(PeerRpcError::TenantMismatch);
72        }
73        if envelope.cluster_id != self.config.local_cluster_id {
74            return Err(PeerRpcError::ClusterMismatch);
75        }
76        if envelope.target_core_id != self.config.local_core_id {
77            return Err(PeerRpcError::TargetMismatch);
78        }
79        if !self
80            .local_protocol_version
81            .is_compatible_with(envelope.protocol_version)
82        {
83            return Err(PeerRpcError::ProtocolMismatch);
84        }
85        let window_ms = self.config.nonce_window_ms.max(1);
86        if envelope.timestamp_ms >= envelope.expires_at_ms
87            || envelope.expires_at_ms <= now_ms
88            || envelope.timestamp_ms > now_ms.saturating_add(window_ms)
89            || now_ms > envelope.timestamp_ms.saturating_add(window_ms)
90        {
91            return Err(PeerRpcError::Expired);
92        }
93        if envelope.body_hash != payload_hash(&envelope.payload) {
94            return Err(PeerRpcError::InvalidBodyHash);
95        }
96        if let Some(trace) = &envelope.trace {
97            if trace.trace_id != envelope.trace_id
98                || trace.tenant_id != envelope.tenant_id
99                || trace.current_core_id != envelope.source_core_id
100            {
101                return Err(PeerRpcError::InvalidEnvelope(
102                    "trace_context_mismatch".to_string(),
103                ));
104            }
105        }
106        let nonce_expires_at_ms = envelope.expires_at_ms.min(now_ms.saturating_add(window_ms));
107        self.nonce_store
108            .check_and_record(&envelope.nonce, nonce_expires_at_ms, now_ms)?;
109        Ok(())
110    }
111}
112
113fn validate_envelope_identifiers(envelope: &PeerRpcEnvelope) -> Result<(), PeerRpcError> {
114    for (kind, value) in [
115        ("PeerRequestId", envelope.request_id.as_str()),
116        ("TraceId", envelope.trace_id.as_str()),
117        ("PeerNonce", envelope.nonce.as_str()),
118    ] {
119        validate_identifier(kind, value)
120            .map_err(|_| PeerRpcError::InvalidEnvelope("invalid_identifier".to_string()))?;
121    }
122    if let Some(idempotency_key) = &envelope.idempotency_key {
123        validate_identifier("IdempotencyKey", idempotency_key)
124            .map_err(|_| PeerRpcError::InvalidEnvelope("invalid_idempotency_key".to_string()))?;
125    }
126    envelope
127        .source_core_id
128        .validate()
129        .and_then(|_| envelope.target_core_id.validate())
130        .and_then(|_| envelope.tenant_id.validate())
131        .and_then(|_| envelope.cluster_id.validate())
132        .and_then(|_| envelope.capability.validate())
133        .map_err(|_| PeerRpcError::InvalidEnvelope("invalid_identifier".to_string()))
134}
135
136/// Returns the hexadecimal SHA-256 digest of an application payload.
137pub fn payload_hash(payload: &[u8]) -> String {
138    let digest = Sha256::digest(payload);
139    hex_encode(&digest)
140}
141
142/// Hash signed by peer bearer tokens. It binds routing metadata and payload integrity.
143pub fn envelope_signing_hash(envelope: &PeerRpcEnvelope) -> String {
144    let mut hasher = Sha256::new();
145    hash_field(&mut hasher, envelope.request_id.as_bytes());
146    hash_field(&mut hasher, envelope.trace_id.as_bytes());
147    hasher.update(envelope.protocol_version.as_u16().to_be_bytes());
148    hash_field(&mut hasher, envelope.source_core_id.as_str().as_bytes());
149    hash_field(&mut hasher, envelope.target_core_id.as_str().as_bytes());
150    hash_field(&mut hasher, envelope.tenant_id.as_str().as_bytes());
151    hash_field(&mut hasher, envelope.cluster_id.as_str().as_bytes());
152    hasher.update(envelope.timestamp_ms.to_be_bytes());
153    hasher.update(envelope.expires_at_ms.to_be_bytes());
154    hash_field(&mut hasher, envelope.nonce.as_bytes());
155    hash_field(&mut hasher, envelope.capability.as_str().as_bytes());
156    hash_field(&mut hasher, envelope.body_hash.as_bytes());
157    hash_optional_field(&mut hasher, envelope.idempotency_key.as_deref());
158    if let Some(trace) = &envelope.trace {
159        hasher.update([1]);
160        hash_field(&mut hasher, trace.trace_id.as_bytes());
161        hash_field(&mut hasher, trace.span_id.as_bytes());
162        hash_optional_field(&mut hasher, trace.parent_span_id.as_deref());
163        hash_field(&mut hasher, trace.originating_core_id.as_str().as_bytes());
164        hash_field(&mut hasher, trace.current_core_id.as_str().as_bytes());
165        hash_field(&mut hasher, trace.tenant_id.as_str().as_bytes());
166        hash_optional_field(&mut hasher, trace.command_id.as_deref());
167    } else {
168        hasher.update([0]);
169    }
170    hex_encode(&hasher.finalize())
171}
172
173fn hash_field(hasher: &mut Sha256, value: &[u8]) {
174    hasher.update((value.len() as u64).to_be_bytes());
175    hasher.update(value);
176}
177
178fn hash_optional_field(hasher: &mut Sha256, value: Option<&str>) {
179    if let Some(value) = value {
180        hasher.update([1]);
181        hash_field(hasher, value.as_bytes());
182    } else {
183        hasher.update([0]);
184    }
185}
186
187/// Returns the stable query endpoint path.
188pub fn route_for_query() -> &'static str {
189    PEER_QUERY_PATH
190}
191
192/// Returns the stable command endpoint path.
193pub fn route_for_command() -> &'static str {
194    PEER_COMMAND_PATH
195}
196
197fn hex_encode(bytes: &[u8]) -> String {
198    const HEX: &[u8; 16] = b"0123456789abcdef";
199    let mut out = String::with_capacity(bytes.len() * 2);
200    for byte in bytes {
201        out.push(HEX[(byte >> 4) as usize] as char);
202        out.push(HEX[(byte & 0x0f) as usize] as char);
203    }
204    out
205}