Skip to main content

rings_node/onion/circuit/
crypto.rs

1#[cfg(rings_native)]
2use std::sync::atomic::AtomicU64;
3#[cfg(rings_native)]
4use std::sync::atomic::Ordering;
5
6use bytes::Bytes;
7use rings_core::dht::Did;
8use rings_core::ecc::elgamal::impls::secp256k1::encrypt_aead_with_rng;
9use rings_core::ecc::elgamal::impls::secp256k1::AeadCiphertext;
10use rings_core::ecc::PublicKey;
11use rings_core::message::MessageVerification;
12use rings_core::session::SessionSk;
13use rings_core::utils::get_epoch_ms;
14use serde::Serialize;
15
16use super::cell::seal_message;
17use super::codec::OnionWireMessage;
18use super::OnionAuthenticatedPayload;
19use super::OnionBackwardFrame;
20use super::OnionBackwardNonce;
21use super::OnionBackwardPath;
22use super::OnionBackwardSequence;
23use super::OnionCircuitId;
24use super::OnionCircuitPayload;
25use super::OnionClientReturn;
26use super::OnionForwardFrame;
27use super::OnionForwardLayer;
28use super::OnionForwardNonce;
29use super::OnionForwardSequence;
30use super::OnionLink;
31use super::OnionLinkSender;
32use super::OnionReturnId;
33use super::OnionVerifiedPayload;
34use super::ONION_AEAD_NAMESPACE;
35use super::ONION_FORWARD_EXPIRY_QUANTUM_MS;
36use super::ONION_FORWARD_PAYLOAD_TTL_MS;
37use crate::error::Error;
38use crate::error::Result;
39use crate::extension::ext::Scope;
40use crate::onion::OnionExitDescriptor;
41use crate::onion::OnionRoute;
42use crate::onion::OnionRouteError;
43use crate::onion::OnionRouteHop;
44#[cfg(rings_native)]
45use crate::onion::OnionServiceName;
46
47/// Encode the first forward frame for `route`.
48///
49/// Pre: `payload.service` names the same service that selected `route`.
50/// Post: the encrypted exit layer cannot carry a payload for a service different from the selected
51/// exit descriptor service.
52pub fn encode_initial_forward(
53    client: OnionClientReturn,
54    route: &OnionRoute,
55    circuit_id: OnionCircuitId,
56    payload: OnionCircuitPayload,
57) -> Result<(Did, Bytes)> {
58    encode_initial_forward_link(client, route, circuit_id, payload)
59        .map(|(link, payload)| (link.peer, payload))
60}
61
62/// Encode the first forward frame while preserving its authenticated link as one value.
63pub(crate) fn encode_initial_forward_link(
64    client: OnionClientReturn,
65    route: &OnionRoute,
66    circuit_id: OnionCircuitId,
67    payload: OnionCircuitPayload,
68) -> Result<(OnionLink, Bytes)> {
69    validate_route_payload_service(route, &payload)?;
70    let first = route_first_link(route)?;
71    let layer = build_forward_layers(
72        client,
73        route.encryption_hops(),
74        circuit_id,
75        OnionForwardSequence::FIRST,
76        payload,
77    )?;
78    let frame = OnionForwardFrame { circuit_id, layer };
79    seal_message(&OnionWireMessage::Forward(frame), first.recipient, None)
80        .map(|payload| (first, payload))
81}
82
83/// Stable edge-id plan for a long-lived onion circuit.
84///
85/// Invariant: `edge_circuit_ids.len() == route.encryption_hops().len()` and
86/// `first_circuit_id == edge_circuit_ids[0]`. Reusing one path for every payload in a stream
87/// preserves the exit-side stream key and refreshes the same relay return edges.
88#[cfg(rings_native)]
89#[derive(Debug)]
90pub(crate) struct OnionCircuitPath {
91    route: OnionRoute,
92    first_circuit_id: OnionCircuitId,
93    edge_circuit_ids: Vec<OnionCircuitId>,
94    next_forward_sequence: AtomicU64,
95}
96
97#[cfg(rings_native)]
98impl OnionCircuitPath {
99    /// Build a stable circuit path for one route.
100    pub(crate) fn new(route: OnionRoute, first_circuit_id: OnionCircuitId) -> Result<Self> {
101        let edge_circuit_ids = edge_circuit_ids(route.encryption_hops().len(), first_circuit_id)?;
102        Ok(Self {
103            route,
104            first_circuit_id,
105            edge_circuit_ids,
106            next_forward_sequence: AtomicU64::new(0),
107        })
108    }
109
110    /// Encode one forward payload over this stable path.
111    pub(crate) fn encode_forward(
112        &self,
113        client: OnionClientReturn,
114        payload: OnionCircuitPayload,
115    ) -> Result<(OnionLink, Bytes)> {
116        validate_route_payload_service(&self.route, &payload)?;
117        let sequence = self
118            .next_forward_sequence
119            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
120                value.checked_add(1)
121            })
122            .map(OnionForwardSequence::new)
123            .map_err(|_| Error::OnionRouteError(OnionRouteError::SequenceExhausted))?;
124        let first = route_first_link(&self.route)?;
125        let layer = build_forward_layers_with_ids(
126            client,
127            self.route.encryption_hops(),
128            self.edge_circuit_ids.as_slice(),
129            sequence,
130            payload,
131        )?;
132        let frame = OnionForwardFrame {
133            circuit_id: self.first_circuit_id,
134            layer,
135        };
136        seal_message(&OnionWireMessage::Forward(frame), first.recipient, None)
137            .map(|payload| (first, payload))
138    }
139
140    /// Return the canonical service selected by this path's route.
141    pub(crate) fn service_name(&self) -> &OnionServiceName {
142        self.route.service_name()
143    }
144}
145
146/// Return the first overlay hop of a route that was validated at construction.
147///
148/// Pre: `route` was built by the route module constructor.
149/// Post: result is the first encrypted hop DID used by forward encoding.
150pub fn route_first_hop(route: &OnionRoute) -> Result<Did> {
151    route_first_link(route).map(|link| link.peer)
152}
153
154/// Return the first overlay peer and hop encryption recipient as one inseparable link value.
155pub(crate) fn route_first_link(route: &OnionRoute) -> Result<OnionLink> {
156    route
157        .encryption_hops()
158        .first()
159        .map(|hop| OnionLink::new(hop.did, hop.session_public_key))
160        .ok_or_else(|| Error::OnionRouteError(OnionRouteError::RouteHasNoHops))
161}
162
163/// Send a response payload back to the immediate return peer.
164pub async fn send_backward(
165    link_sender: &OnionLinkSender,
166    scope: &Scope,
167    signer: &SessionSk,
168    path: OnionBackwardPath,
169    sequence: OnionBackwardSequence,
170    payload: OnionCircuitPayload,
171) -> Result<()> {
172    let frame = OnionBackwardFrame {
173        circuit_id: path.circuit_id,
174        payload: encrypt_client_payload_at_sequence(
175            path.client.return_id,
176            sequence,
177            payload,
178            path.client.session_public_key,
179            signer,
180        )?,
181    };
182    let payload = seal_message(
183        &OnionWireMessage::Backward(frame),
184        path.return_session_public_key,
185        None,
186    )?;
187    link_sender
188        .send_sealed(
189            scope.clone(),
190            OnionLink::new(path.return_peer, path.return_session_public_key),
191            payload,
192        )
193        .await
194}
195
196fn build_forward_layers(
197    client: OnionClientReturn,
198    hops: &[OnionRouteHop],
199    first_circuit_id: OnionCircuitId,
200    sequence: OnionForwardSequence,
201    payload: OnionCircuitPayload,
202) -> Result<AeadCiphertext> {
203    let circuit_ids = edge_circuit_ids(hops.len(), first_circuit_id)?;
204    build_forward_layers_with_ids(client, hops, circuit_ids.as_slice(), sequence, payload)
205}
206
207fn build_forward_layers_with_ids(
208    client: OnionClientReturn,
209    hops: &[OnionRouteHop],
210    circuit_ids: &[OnionCircuitId],
211    sequence: OnionForwardSequence,
212    payload: OnionCircuitPayload,
213) -> Result<AeadCiphertext> {
214    let Some(exit) = hops.last().copied() else {
215        return Err(Error::OnionRouteError(OnionRouteError::RouteHasNoHops));
216    };
217    if hops.len() != circuit_ids.len() {
218        return Err(Error::OnionRouteError(
219            OnionRouteError::CircuitPathLengthMismatch {
220                hop_count: hops.len(),
221                edge_count: circuit_ids.len(),
222            },
223        ));
224    }
225    let expires_at_ms = quantized_forward_expiry(get_epoch_ms());
226    let exit_circuit_id = *circuit_ids
227        .last()
228        .ok_or_else(|| Error::OnionRouteError(OnionRouteError::RouteHasNoHops))?;
229    let exit_return_session_public_key = hops
230        .iter()
231        .rev()
232        .nth(1)
233        .map_or(client.session_public_key, |hop| hop.session_public_key);
234    let mut layer = encrypt_forward_layer(
235        exit_circuit_id,
236        OnionForwardLayer::Exit {
237            client,
238            return_session_public_key: exit_return_session_public_key,
239            expires_at_ms,
240            forward_nonce: OnionForwardNonce::random(),
241            forward_sequence: sequence,
242            payload,
243        },
244        exit.session_public_key,
245    )?;
246
247    for (index, hop) in hops.iter().copied().enumerate().rev().skip(1) {
248        let next_index = index.saturating_add(1);
249        let next_hop = hops
250            .get(next_index)
251            .copied()
252            .ok_or_else(|| Error::OnionRouteError(OnionRouteError::MissingNextHop))?;
253        let current_circuit_id = circuit_ids
254            .get(index)
255            .copied()
256            .ok_or_else(|| Error::OnionRouteError(OnionRouteError::MissingNextHop))?;
257        let next_circuit_id = circuit_ids
258            .get(next_index)
259            .copied()
260            .ok_or_else(|| Error::OnionRouteError(OnionRouteError::MissingNextHop))?;
261        layer = encrypt_forward_layer(
262            current_circuit_id,
263            OnionForwardLayer::Relay {
264                next_hop: next_hop.did,
265                next_circuit_id,
266                next_session_public_key: next_hop.session_public_key,
267                return_session_public_key: if index == 0 {
268                    client.session_public_key
269                } else {
270                    hops.get(index.saturating_sub(1))
271                        .map(|previous| previous.session_public_key)
272                        .ok_or_else(|| Error::OnionRouteError(OnionRouteError::MissingNextHop))?
273                },
274                inner: layer,
275            },
276            hop.session_public_key,
277        )?;
278    }
279    Ok(layer)
280}
281
282/// Quantize authenticated expiry to a coarse wall-clock boundary.
283///
284/// Law: every timestamp in one quantum maps to the same advertised boundary, so exit validation
285/// retains a finite TTL while the encrypted layer does not preserve byte-accurate client clock
286/// skew. Saturation remains fail-closed at the maximum representable instant.
287fn quantized_forward_expiry(now_ms: u128) -> u128 {
288    let deadline = now_ms.saturating_add(ONION_FORWARD_PAYLOAD_TTL_MS);
289    deadline
290        .saturating_add(ONION_FORWARD_EXPIRY_QUANTUM_MS - 1)
291        .checked_div(ONION_FORWARD_EXPIRY_QUANTUM_MS)
292        .and_then(|bucket| bucket.checked_mul(ONION_FORWARD_EXPIRY_QUANTUM_MS))
293        .unwrap_or(u128::MAX)
294}
295
296fn edge_circuit_ids(
297    hop_count: usize,
298    first_circuit_id: OnionCircuitId,
299) -> Result<Vec<OnionCircuitId>> {
300    edge_circuit_ids_with(hop_count, first_circuit_id, OnionCircuitId::random)
301}
302
303pub(super) fn edge_circuit_ids_with(
304    hop_count: usize,
305    first_circuit_id: OnionCircuitId,
306    mut next_id: impl FnMut() -> OnionCircuitId,
307) -> Result<Vec<OnionCircuitId>> {
308    const MAX_ALLOCATION_ATTEMPTS_PER_EDGE: usize = 16;
309    if hop_count == 0 || hop_count > usize::from(super::MAX_ONION_CIRCUIT_HOPS) {
310        return Err(Error::OnionRouteError(
311            OnionRouteError::HopCountOutOfBounds {
312                hop_count,
313                max_hops: super::MAX_ONION_CIRCUIT_HOPS,
314            },
315        ));
316    }
317    let mut ids = Vec::with_capacity(hop_count);
318    ids.push(first_circuit_id);
319    while ids.len() < hop_count {
320        let next = (0..MAX_ALLOCATION_ATTEMPTS_PER_EDGE)
321            .map(|_| next_id())
322            .find(|candidate| !ids.contains(candidate))
323            .ok_or_else(|| Error::OnionRouteError(OnionRouteError::CircuitIdAllocationFailed))?;
324        ids.push(next);
325    }
326    Ok(ids)
327}
328
329fn encrypt_forward_layer(
330    circuit_id: OnionCircuitId,
331    layer: OnionForwardLayer,
332    recipient: PublicKey<33>,
333) -> Result<AeadCiphertext> {
334    let plaintext = rings_codec::serialize(&layer).map_err(|_| Error::EncodeError)?;
335    let aad = onion_aead_context(OnionAeadDirection::Forward, circuit_id)?;
336    let mut rng = rand::thread_rng();
337    encrypt_aead_with_rng(&plaintext, &aad, recipient, &mut rng).map_err(Error::CoreError)
338}
339
340pub(super) fn decrypt_forward_layer(
341    session_sk: &SessionSk,
342    circuit_id: OnionCircuitId,
343    sealed: &AeadCiphertext,
344) -> Result<OnionForwardLayer> {
345    let aad = onion_aead_context(OnionAeadDirection::Forward, circuit_id)?;
346    let plaintext = session_sk
347        .decrypt_elgamal_aead(sealed, &aad)
348        .map_err(Error::CoreError)?;
349    rings_codec::deserialize(&plaintext).map_err(|_| Error::DecodeError)
350}
351
352#[cfg(test)]
353pub(super) fn encrypt_client_payload(
354    return_id: OnionReturnId,
355    payload: OnionCircuitPayload,
356    recipient: PublicKey<33>,
357    signer: &SessionSk,
358) -> Result<AeadCiphertext> {
359    encrypt_client_payload_at_sequence(
360        return_id,
361        OnionBackwardSequence::FIRST,
362        payload,
363        recipient,
364        signer,
365    )
366}
367
368pub(super) fn encrypt_client_payload_at_sequence(
369    return_id: OnionReturnId,
370    sequence: OnionBackwardSequence,
371    payload: OnionCircuitPayload,
372    recipient: PublicKey<33>,
373    signer: &SessionSk,
374) -> Result<AeadCiphertext> {
375    let authenticated =
376        OnionAuthenticatedPayload::new_signed_at_sequence(return_id, sequence, payload, signer)?;
377    let plaintext = rings_codec::serialize(&authenticated).map_err(|_| Error::EncodeError)?;
378    // The outer hop cell authenticates the edge-local circuit and direction. This inner payload
379    // deliberately remains stable while relays rewrite edge ids; its signed transcript binds the
380    // client-only return id, nonce, monotonic sequence, exit session key, and payload bytes.
381    let aad = backward_aead_context()?;
382    let mut rng = rand::thread_rng();
383    encrypt_aead_with_rng(&plaintext, &aad, recipient, &mut rng).map_err(Error::CoreError)
384}
385
386pub(super) fn decrypt_client_payload(
387    session_sk: &SessionSk,
388    sealed: &AeadCiphertext,
389) -> Result<OnionAuthenticatedPayload> {
390    let aad = backward_aead_context()?;
391    let plaintext = session_sk
392        .decrypt_elgamal_aead(sealed, &aad)
393        .map_err(Error::CoreError)?;
394    rings_codec::deserialize(&plaintext).map_err(|_| Error::DecodeError)
395}
396
397impl OnionAuthenticatedPayload {
398    /// Sign one backward payload with a fresh replay nonce.
399    pub fn new_signed(
400        return_id: OnionReturnId,
401        payload: OnionCircuitPayload,
402        signer: &SessionSk,
403    ) -> Result<Self> {
404        Self::new_signed_at_sequence(return_id, OnionBackwardSequence::FIRST, payload, signer)
405    }
406
407    /// Sign one backward payload at a caller-owned monotonic circuit sequence.
408    pub fn new_signed_at_sequence(
409        return_id: OnionReturnId,
410        sequence: OnionBackwardSequence,
411        payload: OnionCircuitPayload,
412        signer: &SessionSk,
413    ) -> Result<Self> {
414        let nonce = OnionBackwardNonce::random();
415        let authentication = MessageVerification::new(
416            &backward_payload_authentication_data(
417                return_id,
418                nonce,
419                sequence,
420                signer.session_public_key(),
421                &payload,
422            )?,
423            signer,
424        )
425        .map_err(Error::CoreError)?;
426        Ok(Self {
427            return_id,
428            nonce,
429            sequence,
430            authentication,
431            payload,
432        })
433    }
434
435    /// Verify that a client-decrypted backward payload was signed by the selected exit session.
436    ///
437    /// Invariant: accepted backward payloads satisfy all three identity equalities:
438    /// signer account DID equals descriptor DID, signer account public key equals descriptor public
439    /// key, and signer session DID equals the descriptor session encryption key DID. The signed
440    /// transcript also binds the client/exit return id, per-frame nonce, exit session public key,
441    /// and payload.
442    pub fn into_verified_payload(
443        self,
444        return_id: OnionReturnId,
445        expected_exit: &OnionExitDescriptor,
446    ) -> Result<OnionVerifiedPayload> {
447        if self.return_id != return_id {
448            return Err(Error::OnionRouteError(
449                OnionRouteError::BackwardReturnIdMismatch,
450            ));
451        }
452        let signer = &self.authentication.session;
453        if signer.account_did() != expected_exit.did {
454            return Err(Error::OnionRouteError(
455                OnionRouteError::BackwardSignerMismatch,
456            ));
457        }
458        let public_key = signer
459            .account_verification_pubkey()
460            .map_err(Error::CoreError)?;
461        if public_key != expected_exit.public_key {
462            return Err(Error::OnionRouteError(
463                OnionRouteError::BackwardAccountKeyMismatch,
464            ));
465        }
466        if signer.session_did() != Did::from(expected_exit.session_public_key.address()) {
467            return Err(Error::OnionRouteError(
468                OnionRouteError::BackwardSessionKeyMismatch,
469            ));
470        }
471        let data = backward_payload_authentication_data(
472            return_id,
473            self.nonce,
474            self.sequence,
475            expected_exit.session_public_key,
476            &self.payload,
477        )?;
478        if !self.authentication.verify_unexpired(&data) {
479            return Err(Error::OnionRouteError(
480                OnionRouteError::InvalidBackwardSignature,
481            ));
482        }
483        Ok(OnionVerifiedPayload {
484            return_id: self.return_id,
485            nonce: self.nonce,
486            sequence: self.sequence,
487            payload: self.payload,
488        })
489    }
490}
491
492#[derive(Serialize)]
493struct OnionAeadContext {
494    namespace: &'static str,
495    direction: OnionAeadDirection,
496    circuit_id: OnionCircuitId,
497}
498
499#[derive(Serialize)]
500struct OnionBackwardAuthenticationData<'a> {
501    namespace: &'static str,
502    direction: OnionAeadDirection,
503    return_id: OnionReturnId,
504    nonce: OnionBackwardNonce,
505    sequence: OnionBackwardSequence,
506    exit_session_public_key: PublicKey<33>,
507    payload: &'a OnionCircuitPayload,
508}
509
510#[derive(Clone, Copy, Serialize)]
511pub(super) enum OnionAeadDirection {
512    Forward,
513    Backward,
514}
515
516fn onion_aead_context(
517    direction: OnionAeadDirection,
518    circuit_id: OnionCircuitId,
519) -> Result<Vec<u8>> {
520    rings_codec::serialize(&OnionAeadContext {
521        namespace: ONION_AEAD_NAMESPACE,
522        direction,
523        circuit_id,
524    })
525    .map_err(|_| Error::EncodeError)
526}
527
528fn backward_aead_context() -> Result<Vec<u8>> {
529    rings_codec::serialize(&OnionAeadDirectionContext {
530        namespace: ONION_AEAD_NAMESPACE,
531        direction: OnionAeadDirection::Backward,
532    })
533    .map_err(|_| Error::EncodeError)
534}
535
536fn backward_payload_authentication_data(
537    return_id: OnionReturnId,
538    nonce: OnionBackwardNonce,
539    sequence: OnionBackwardSequence,
540    exit_session_public_key: PublicKey<33>,
541    payload: &OnionCircuitPayload,
542) -> Result<Vec<u8>> {
543    rings_codec::serialize(&OnionBackwardAuthenticationData {
544        namespace: ONION_AEAD_NAMESPACE,
545        direction: OnionAeadDirection::Backward,
546        return_id,
547        nonce,
548        sequence,
549        exit_session_public_key,
550        payload,
551    })
552    .map_err(|_| Error::EncodeError)
553}
554
555#[derive(Serialize)]
556struct OnionAeadDirectionContext {
557    namespace: &'static str,
558    direction: OnionAeadDirection,
559}
560
561fn validate_route_payload_service(route: &OnionRoute, payload: &OnionCircuitPayload) -> Result<()> {
562    if !payload.is_service(route.service_name()) {
563        return Err(Error::OnionRouteError(
564            OnionRouteError::PayloadServiceMismatch {
565                payload_service: payload.service().to_string(),
566                route_service: route.service().to_string(),
567            },
568        ));
569    }
570    Ok(())
571}