Skip to main content

rings_node/onion/circuit/
reducer.rs

1use std::collections::btree_map::Entry;
2use std::collections::BTreeMap;
3use std::sync::Arc;
4
5use bytes::Bytes;
6use rings_core::dht::Did;
7use rings_core::ecc::elgamal::impls::secp256k1::AeadCiphertext;
8use rings_core::ecc::PublicKey;
9use serde::Deserialize;
10use serde::Serialize;
11
12use super::cell::encode_message;
13use super::cell::OnionCellBucket;
14use super::codec::OnionCircuitInput;
15use super::codec::OnionWireMessage;
16use super::protocol::OnionCircuitCapabilities;
17use super::OnionBackwardFrame;
18use super::OnionCircuitId;
19use super::OnionCircuitPayload;
20use super::OnionClientReturn;
21use super::OnionForwardFrame;
22use super::OnionForwardLayer;
23use super::OnionForwardNonce;
24use super::OnionForwardSequence;
25use super::MAX_ONION_RELAY_CIRCUITS;
26use super::ONION_FORWARD_MAX_VALIDITY_MS;
27use super::ONION_RELAY_RETURN_TTL_MS;
28use crate::error::Error;
29use crate::error::Result;
30use crate::extension::ext::Transition;
31use crate::onion::OnionRouteError;
32
33#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
34pub(super) struct RelayReturnKey {
35    pub(super) circuit_id: OnionCircuitId,
36    pub(super) next_hop: Did,
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub(super) struct RelayReturnEdge {
41    pub(super) key: RelayReturnKey,
42    pub(super) previous_hop: Did,
43    pub(super) previous_circuit_id: OnionCircuitId,
44    pub(super) previous_session_public_key: PublicKey<33>,
45}
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48struct RelayReturnEntry {
49    previous_hop: Did,
50    previous_circuit_id: OnionCircuitId,
51    previous_session_public_key: PublicKey<33>,
52    expires_at_ms: u128,
53}
54
55/// Stateful return-hop table for encrypted relay circuits.
56///
57/// Invariant: every `(next_edge_id, next_hop) -> (previous_edge_id, previous_hop)` entry
58/// represents exactly one live reverse edge learned from a prior forward relay action.
59/// Preservation: forward relay insertion purges expired entries before capacity checks and never
60/// rewrites a live key to a different previous hop; backward frames purge expired entries before
61/// lookup and refresh only the matched edge.
62/// Return-state removal is TTL-based because backward close semantics are encrypted to the
63/// client and are not authenticated to relays.
64#[derive(Clone, Debug, Default, Eq, PartialEq)]
65pub struct OnionCircuitState {
66    relay_returns: Arc<BTreeMap<RelayReturnKey, RelayReturnEntry>>,
67}
68
69impl OnionCircuitState {
70    #[cfg(test)]
71    pub(super) fn relay_return_count(&self) -> usize {
72        self.relay_returns.len()
73    }
74
75    #[cfg(test)]
76    pub(super) fn shares_return_table_with(&self, other: &Self) -> bool {
77        Arc::ptr_eq(&self.relay_returns, &other.relay_returns)
78    }
79}
80
81/// Effects emitted by the route-aware circuit reducer.
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub enum OnionCircuitEffect {
84    /// Run forward-layer crypto at the shell boundary and re-inject the decoded layer.
85    DecryptCell {
86        /// Authenticated immediate sender.
87        from: Did,
88        /// Public padding class; direction and exact length remain encrypted.
89        bucket: OnionCellBucket,
90        /// Hop-encrypted fixed-size cell payload.
91        sealed: AeadCiphertext,
92    },
93    /// Decrypt one forward onion layer after its outer cell has authenticated the direction.
94    DecryptForward {
95        /// Authenticated immediate sender.
96        from: Did,
97        /// Cell receipt time captured once at the shell boundary.
98        received_at_ms: u128,
99        /// Public padding class to preserve on the next edge.
100        bucket: OnionCellBucket,
101        /// Edge-local circuit id bound into the layer AEAD.
102        circuit_id: OnionCircuitId,
103        /// Forward onion layer encrypted to this node.
104        payload: AeadCiphertext,
105    },
106    /// Encrypt and send one fixed-size cell at the shell boundary.
107    SealAndSend {
108        /// Next hop.
109        to: Did,
110        /// Next hop session key authenticated inside the current layer.
111        recipient: PublicKey<33>,
112        /// Padding class preserved across relay edges.
113        bucket: OnionCellBucket,
114        /// Encoded direction and frame protected by the cell AEAD.
115        encoded_message: Bytes,
116    },
117    /// A forward frame reached the exit.
118    Exit {
119        /// Authenticated immediate sender.
120        from: Did,
121        /// Random circuit correlation id.
122        circuit_id: OnionCircuitId,
123        /// Immediate return peer.
124        return_peer: Did,
125        /// Immediate return peer session key for the first backward cell.
126        return_session_public_key: PublicKey<33>,
127        /// Client return key.
128        client: OnionClientReturn,
129        /// Replay token consumed by one-shot exit operations; stream frames use `forward_sequence`.
130        forward_nonce: OnionForwardNonce,
131        /// Monotonic client-to-exit sequence within this circuit.
132        forward_sequence: OnionForwardSequence,
133        /// Application payload.
134        payload: OnionCircuitPayload,
135    },
136    /// Decrypt a backward frame for this local client at the shell boundary.
137    DecryptClient {
138        /// Authenticated immediate sender.
139        from: Did,
140        /// Random circuit correlation id.
141        circuit_id: OnionCircuitId,
142        /// AEAD payload encrypted to the client session public key.
143        payload: AeadCiphertext,
144    },
145}
146
147/// Pure state relation for onion circuits.
148///
149/// ```text
150/// CellObserved(encrypted)      -> [DecryptCell]
151/// CellReady(forward relay)     -> state' with return edge, [SealAndSend next]
152/// CellReady(forward exit)      -> state, [Exit]
153/// CellReady(backward match)    -> state' with refreshed edge, [SealAndSend previous]
154/// CellReady(backward no match) -> state, [DecryptClient]
155/// CellReady(cover)             -> state, []
156/// ```
157///
158/// Law: replaying `apply(state, input)` with the same values returns the same `(state', effects)`.
159/// Clocks, crypto, IO, and locks are represented by effects and live in the shell.
160#[derive(Clone, Debug)]
161pub(super) struct OnionCircuitReducer {
162    capabilities: OnionCircuitCapabilities,
163}
164
165impl OnionCircuitReducer {
166    pub(super) const fn new(capabilities: OnionCircuitCapabilities) -> Self {
167        Self { capabilities }
168    }
169
170    pub(super) fn apply(
171        &self,
172        state: &OnionCircuitState,
173        input: OnionCircuitInput,
174    ) -> Transition<OnionCircuitState, OnionCircuitEffect> {
175        let mut state = state.clone();
176        let effect = match input {
177            OnionCircuitInput::CellObserved {
178                from,
179                bucket,
180                sealed,
181            } => Ok(Some(OnionCircuitEffect::DecryptCell {
182                from,
183                bucket,
184                sealed,
185            })),
186            OnionCircuitInput::CellReady {
187                from,
188                received_at_ms,
189                bucket,
190                message,
191            } => self.advance_cell(from, received_at_ms, bucket, message, &mut state),
192            OnionCircuitInput::ForwardReady {
193                from,
194                received_at_ms,
195                bucket,
196                circuit_id,
197                layer,
198            } => self
199                .advance_forward(from, received_at_ms, bucket, circuit_id, layer, &mut state)
200                .map(Some),
201        };
202
203        match effect {
204            Ok(Some(effect)) => Transition::with(state, vec![effect]),
205            Ok(None) => Transition::pure(state),
206            Err(error) => {
207                tracing::debug!("drop onion circuit message: {error}");
208                Transition::pure(state)
209            }
210        }
211    }
212
213    fn advance_cell(
214        &self,
215        from: Did,
216        received_at_ms: u128,
217        bucket: OnionCellBucket,
218        message: OnionWireMessage,
219        state: &mut OnionCircuitState,
220    ) -> Result<Option<OnionCircuitEffect>> {
221        match message {
222            OnionWireMessage::Forward(frame) => {
223                if !self.capabilities.accepts_forward_layers() {
224                    return Err(Error::NoPermission);
225                }
226                Ok(Some(OnionCircuitEffect::DecryptForward {
227                    from,
228                    received_at_ms,
229                    bucket,
230                    circuit_id: frame.circuit_id,
231                    payload: frame.layer,
232                }))
233            }
234            OnionWireMessage::Backward(frame) => self
235                .advance_backward(from, received_at_ms, bucket, frame, state)
236                .map(Some),
237            OnionWireMessage::Cover => Ok(None),
238        }
239    }
240
241    fn advance_forward(
242        &self,
243        from: Did,
244        received_at_ms: u128,
245        bucket: OnionCellBucket,
246        circuit_id: OnionCircuitId,
247        layer: OnionForwardLayer,
248        state: &mut OnionCircuitState,
249    ) -> Result<OnionCircuitEffect> {
250        if !self.capabilities.accepts_forward_layers() {
251            return Err(Error::NoPermission);
252        }
253        match layer {
254            OnionForwardLayer::Relay {
255                next_hop,
256                next_circuit_id,
257                next_session_public_key,
258                return_session_public_key,
259                inner,
260            } => {
261                self.validate_relay_forward()?;
262                remember_return_hop(
263                    state,
264                    MAX_ONION_RELAY_CIRCUITS,
265                    ONION_RELAY_RETURN_TTL_MS,
266                    RelayReturnEdge {
267                        key: RelayReturnKey {
268                            circuit_id: next_circuit_id,
269                            next_hop,
270                        },
271                        previous_hop: from,
272                        previous_circuit_id: circuit_id,
273                        previous_session_public_key: return_session_public_key,
274                    },
275                    received_at_ms,
276                )?;
277                encode_message(&OnionWireMessage::Forward(OnionForwardFrame {
278                    circuit_id: next_circuit_id,
279                    layer: inner,
280                }))
281                .map(|encoded_message| OnionCircuitEffect::SealAndSend {
282                    to: next_hop,
283                    recipient: next_session_public_key,
284                    bucket,
285                    encoded_message,
286                })
287            }
288            OnionForwardLayer::Exit {
289                client,
290                return_session_public_key,
291                expires_at_ms,
292                forward_nonce,
293                forward_sequence,
294                payload,
295            } => {
296                if !self.capabilities.permits_exit_layer() {
297                    return Err(Error::NoPermission);
298                }
299                // Invariant: every accepted layer expires while its replay witness is still live.
300                // The upper bound also prevents a malicious client from extending authenticated
301                // validity beyond the finite replay-cache retention contract.
302                if expires_at_ms <= received_at_ms
303                    || expires_at_ms > received_at_ms.saturating_add(ONION_FORWARD_MAX_VALIDITY_MS)
304                {
305                    return Err(Error::OnionRouteError(
306                        OnionRouteError::ForwardPayloadExpired,
307                    ));
308                }
309                Ok(OnionCircuitEffect::Exit {
310                    from,
311                    circuit_id,
312                    return_peer: from,
313                    return_session_public_key,
314                    client,
315                    forward_nonce,
316                    forward_sequence,
317                    payload,
318                })
319            }
320        }
321    }
322
323    fn advance_backward(
324        &self,
325        from: Did,
326        received_at_ms: u128,
327        bucket: OnionCellBucket,
328        frame: OnionBackwardFrame,
329        state: &mut OnionCircuitState,
330    ) -> Result<OnionCircuitEffect> {
331        purge_expired_return_hops(state, received_at_ms);
332        let key = RelayReturnKey {
333            circuit_id: frame.circuit_id,
334            next_hop: from,
335        };
336        if let Some(entry) = state.relay_returns.get(&key).copied() {
337            let previous_hop = entry.previous_hop;
338            let previous_circuit_id = entry.previous_circuit_id;
339            let previous_session_public_key = entry.previous_session_public_key;
340            if let Some(entry) = Arc::make_mut(&mut state.relay_returns).get_mut(&key) {
341                entry.expires_at_ms = received_at_ms.saturating_add(ONION_RELAY_RETURN_TTL_MS);
342            }
343            let encoded_message =
344                encode_message(&OnionWireMessage::Backward(OnionBackwardFrame {
345                    circuit_id: previous_circuit_id,
346                    payload: frame.payload,
347                }))?;
348            return Ok(OnionCircuitEffect::SealAndSend {
349                to: previous_hop,
350                recipient: previous_session_public_key,
351                bucket,
352                encoded_message,
353            });
354        }
355
356        Ok(OnionCircuitEffect::DecryptClient {
357            from,
358            circuit_id: frame.circuit_id,
359            payload: frame.payload,
360        })
361    }
362
363    fn validate_relay_forward(&self) -> Result<()> {
364        if !self.capabilities.permits_relay_layer() {
365            return Err(Error::NoPermission);
366        }
367        // The route constructor bounds honest routes. Untrusted recursive layers are bounded by
368        // the identity-independent crypto window and relay-return capacity instead of an exact
369        // countdown that would disclose this relay's absolute position.
370        Ok(())
371    }
372}
373
374pub(super) fn remember_return_hop(
375    state: &mut OnionCircuitState,
376    max_relay_circuits: usize,
377    ttl_ms: u128,
378    edge: RelayReturnEdge,
379    now_ms: u128,
380) -> Result<()> {
381    let RelayReturnEdge {
382        key,
383        previous_hop,
384        previous_circuit_id,
385        previous_session_public_key,
386    } = edge;
387    purge_expired_return_hops(state, now_ms);
388    let table = Arc::make_mut(&mut state.relay_returns);
389    let table_is_full = table.len() >= max_relay_circuits;
390    let peer_table_is_full = table
391        .values()
392        .filter(|entry| entry.previous_hop == previous_hop)
393        .count()
394        >= max_relay_circuits_per_peer(max_relay_circuits);
395    match table.entry(key) {
396        Entry::Occupied(mut entry) => {
397            if entry.get().previous_hop != previous_hop
398                || entry.get().previous_circuit_id != previous_circuit_id
399                || entry.get().previous_session_public_key != previous_session_public_key
400            {
401                return Err(Error::OnionRouteError(OnionRouteError::ReturnEdgeConflict));
402            }
403            entry.get_mut().expires_at_ms = now_ms.saturating_add(ttl_ms);
404        }
405        Entry::Vacant(entry) => {
406            if table_is_full {
407                return Err(Error::OnionRouteError(OnionRouteError::RelayTableFull));
408            }
409            if peer_table_is_full {
410                return Err(Error::OnionRouteError(OnionRouteError::RelayPeerTableFull));
411            }
412            entry.insert(RelayReturnEntry {
413                previous_hop,
414                previous_circuit_id,
415                previous_session_public_key,
416                expires_at_ms: now_ms.saturating_add(ttl_ms),
417            });
418        }
419    }
420    Ok(())
421}
422
423/// Reserve at most one sixteenth of the global relay-return capacity for any
424/// authenticated previous hop. Therefore one peer cannot exclude honest peers
425/// while the global table has free entries.
426const fn max_relay_circuits_per_peer(max_relay_circuits: usize) -> usize {
427    max_relay_circuits.div_ceil(16)
428}
429
430fn purge_expired_return_hops(state: &mut OnionCircuitState, now_ms: u128) {
431    if state
432        .relay_returns
433        .values()
434        .any(|entry| entry.expires_at_ms <= now_ms)
435    {
436        Arc::make_mut(&mut state.relay_returns).retain(|_, entry| entry.expires_at_ms > now_ms);
437    }
438}