Skip to main content

rings_node/onion/circuit/
cell.rs

1//! Fixed-bucket onion-cell encoding.
2//!
3//! Local producers select the smallest bucket that contains the encoded message. Relays preserve
4//! the already-visible bucket across a circuit edge so that shrinking cells cannot reveal route
5//! position. An authenticated hostile peer can deliberately choose a larger bucket for a small
6//! hidden payload; a relay cannot canonicalize that choice without weakening the fixed-bucket
7//! privacy contract. The crypto admission gate therefore charges `bucket.plaintext_len()` before
8//! decryption. For a byte budget `L` and visible bucket size `b`, at most `floor(L / b)` such cells
9//! can be admitted in one limiter window, independent of their hidden encoded lengths.
10
11use bytes::Bytes;
12use rand::CryptoRng;
13use rand::RngCore;
14use rings_core::ecc::elgamal::impls::secp256k1::encrypt_aead_with_rng;
15use rings_core::ecc::elgamal::impls::secp256k1::AeadCiphertext;
16use rings_core::ecc::PublicKey;
17use rings_core::session::SessionSk;
18use serde::Deserialize;
19use serde::Serialize;
20
21use super::codec::OnionWireMessage;
22use crate::error::Error;
23use crate::error::Result;
24use crate::onion::OnionRouteError;
25
26const CELL_LENGTH_PREFIX_BYTES: usize = size_of::<u32>();
27const ONION_CELL_AEAD_NAMESPACE: &[u8] = b"rings-node:onion-cell:v1";
28
29/// Public size classes used by encrypted onion cells.
30///
31/// The class is intentionally visible while the direction, message discriminant, and exact
32/// application length are encrypted. A small class set bounds padding overhead without exposing
33/// a byte-accurate traffic fingerprint.
34#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub enum OnionCellBucket {
36    /// Up to four KiB of encrypted cell plaintext.
37    KiB4,
38    /// Up to sixteen KiB of encrypted cell plaintext.
39    KiB16,
40    /// Up to sixty-four KiB of encrypted cell plaintext.
41    KiB64,
42    /// Up to 256 KiB of encrypted cell plaintext.
43    KiB256,
44    /// Up to one MiB of encrypted cell plaintext.
45    MiB1,
46    /// Up to four MiB of encrypted cell plaintext.
47    MiB4,
48    /// Up to twelve MiB of encrypted cell plaintext. Admission charges the full visible bucket
49    /// size, irrespective of the hidden encoded length, so an undersized payload cannot bypass
50    /// the relay's per-peer or global byte budget.
51    MiB12,
52}
53
54impl OnionCellBucket {
55    const ALL: [Self; 7] = [
56        Self::KiB4,
57        Self::KiB16,
58        Self::KiB64,
59        Self::KiB256,
60        Self::MiB1,
61        Self::MiB4,
62        Self::MiB12,
63    ];
64
65    /// Return the fixed plaintext length protected by this cell class.
66    pub const fn plaintext_len(self) -> usize {
67        match self {
68            Self::KiB4 => 4 * 1024,
69            Self::KiB16 => 16 * 1024,
70            Self::KiB64 => 64 * 1024,
71            Self::KiB256 => 256 * 1024,
72            Self::MiB1 => 1024 * 1024,
73            Self::MiB4 => 4 * 1024 * 1024,
74            Self::MiB12 => 12 * 1024 * 1024,
75        }
76    }
77
78    fn smallest_for(encoded_len: usize) -> Result<Self> {
79        let required = encoded_len
80            .checked_add(CELL_LENGTH_PREFIX_BYTES)
81            .ok_or_else(|| Error::OnionRouteError(OnionRouteError::CellPayloadTooLarge))?;
82        Self::ALL
83            .into_iter()
84            .find(|bucket| bucket.plaintext_len() >= required)
85            .ok_or_else(|| Error::OnionRouteError(OnionRouteError::CellPayloadTooLarge))
86    }
87
88    fn accepts(self, encoded_len: usize) -> bool {
89        encoded_len
90            .checked_add(CELL_LENGTH_PREFIX_BYTES)
91            .is_some_and(|required| required <= self.plaintext_len())
92    }
93}
94
95/// Hop-to-hop ciphertext whose serialized size reveals only [`OnionCellBucket`].
96#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
97pub(super) struct OnionWireCell {
98    pub(super) bucket: OnionCellBucket,
99    pub(super) sealed: AeadCiphertext,
100}
101
102pub(super) fn encode_message(message: &OnionWireMessage) -> Result<Bytes> {
103    rings_codec::serialize(message)
104        .map(Bytes::from)
105        .map_err(|_| Error::EncodeError)
106}
107
108pub(super) fn seal_message(
109    message: &OnionWireMessage,
110    recipient: PublicKey<33>,
111    bucket: Option<OnionCellBucket>,
112) -> Result<Bytes> {
113    let encoded = encode_message(message)?;
114    seal_encoded_message(&encoded, recipient, bucket)
115}
116
117pub(super) fn seal_encoded_message(
118    encoded: &[u8],
119    recipient: PublicKey<33>,
120    bucket: Option<OnionCellBucket>,
121) -> Result<Bytes> {
122    let mut rng = rand::thread_rng();
123    seal_encoded_message_with_rng(encoded, recipient, bucket, &mut rng)
124}
125
126/// Recover the public size class from a locally sealed cell.
127///
128/// This reads only the hop-visible envelope metadata; it does not decrypt or inspect the hidden
129/// wire message. Endpoint senders use the same class when producing link cover cells.
130pub(super) fn sealed_cell_bucket(payload: &[u8]) -> Result<OnionCellBucket> {
131    rings_codec::deserialize::<OnionWireCell>(payload)
132        .map(|cell| cell.bucket)
133        .map_err(|_| Error::OnionRouteError(OnionRouteError::InvalidCell))
134}
135
136fn seal_encoded_message_with_rng<R: CryptoRng + RngCore>(
137    encoded: &[u8],
138    recipient: PublicKey<33>,
139    bucket: Option<OnionCellBucket>,
140    rng: &mut R,
141) -> Result<Bytes> {
142    let bucket = bucket.map_or_else(|| OnionCellBucket::smallest_for(encoded.len()), Ok)?;
143    if !bucket.accepts(encoded.len()) {
144        return Err(Error::OnionRouteError(OnionRouteError::CellPayloadTooLarge));
145    }
146    let encoded_len = u32::try_from(encoded.len())
147        .map_err(|_| Error::OnionRouteError(OnionRouteError::CellPayloadTooLarge))?;
148    let mut plaintext = vec![0_u8; bucket.plaintext_len()];
149    let encoded_end = CELL_LENGTH_PREFIX_BYTES
150        .checked_add(encoded.len())
151        .ok_or_else(|| Error::OnionRouteError(OnionRouteError::CellPayloadTooLarge))?;
152    plaintext
153        .get_mut(..CELL_LENGTH_PREFIX_BYTES)
154        .ok_or_else(|| Error::OnionRouteError(OnionRouteError::InvalidCell))?
155        .copy_from_slice(&encoded_len.to_le_bytes());
156    plaintext
157        .get_mut(CELL_LENGTH_PREFIX_BYTES..encoded_end)
158        .ok_or_else(|| Error::OnionRouteError(OnionRouteError::InvalidCell))?
159        .copy_from_slice(encoded);
160    rng.fill_bytes(
161        plaintext
162            .get_mut(encoded_end..)
163            .ok_or_else(|| Error::OnionRouteError(OnionRouteError::InvalidCell))?,
164    );
165    let aad = cell_aad(bucket)?;
166    let sealed =
167        encrypt_aead_with_rng(&plaintext, &aad, recipient, rng).map_err(Error::CoreError)?;
168    rings_codec::serialize(&OnionWireCell { bucket, sealed })
169        .map(Bytes::from)
170        .map_err(|_| Error::EncodeError)
171}
172
173pub(super) fn open_cell(
174    session_sk: &SessionSk,
175    bucket: OnionCellBucket,
176    sealed: &AeadCiphertext,
177) -> Result<OnionWireMessage> {
178    let aad = cell_aad(bucket)?;
179    let plaintext = session_sk
180        .decrypt_elgamal_aead(sealed, &aad)
181        .map_err(Error::CoreError)?;
182    if plaintext.len() != bucket.plaintext_len() {
183        return Err(Error::OnionRouteError(OnionRouteError::InvalidCell));
184    }
185    let encoded_len = u32::from_le_bytes(
186        plaintext
187            .get(..CELL_LENGTH_PREFIX_BYTES)
188            .ok_or_else(|| Error::OnionRouteError(OnionRouteError::InvalidCell))?
189            .try_into()
190            .map_err(|_| Error::OnionRouteError(OnionRouteError::InvalidCell))?,
191    ) as usize;
192    if !bucket.accepts(encoded_len) {
193        return Err(Error::OnionRouteError(OnionRouteError::InvalidCell));
194    }
195    let encoded_end = CELL_LENGTH_PREFIX_BYTES
196        .checked_add(encoded_len)
197        .ok_or_else(|| Error::OnionRouteError(OnionRouteError::InvalidCell))?;
198    let encoded = plaintext
199        .get(CELL_LENGTH_PREFIX_BYTES..encoded_end)
200        .ok_or_else(|| Error::OnionRouteError(OnionRouteError::InvalidCell))?;
201    rings_codec::deserialize(encoded).map_err(|_| Error::DecodeError)
202}
203
204fn cell_aad(bucket: OnionCellBucket) -> Result<Vec<u8>> {
205    rings_codec::serialize(&(ONION_CELL_AEAD_NAMESPACE, bucket)).map_err(|_| Error::EncodeError)
206}
207
208#[cfg(test)]
209mod tests {
210    use rings_core::ecc::SecretKey;
211    use rings_core::session::SessionSk;
212
213    use super::*;
214    use crate::onion::circuit::OnionBackwardFrame;
215    use crate::onion::circuit::OnionCircuitId;
216
217    fn session() -> SessionSk {
218        SessionSk::new_with_seckey(&SecretKey::random()).expect("session key")
219    }
220
221    fn backward_message(payload_len: usize) -> OnionWireMessage {
222        let recipient = session();
223        let sealed = encrypt_aead_with_rng(
224            &vec![7_u8; payload_len],
225            b"cell-test",
226            recipient.session_public_key(),
227            &mut rand::thread_rng(),
228        )
229        .expect("encrypt fixture");
230        OnionWireMessage::Backward(OnionBackwardFrame {
231            circuit_id: OnionCircuitId::new([1; 16]),
232            payload: sealed,
233        })
234    }
235
236    #[test]
237    fn test_small_messages_share_one_observable_cell_size() {
238        let recipient = session();
239        let short = seal_message(&backward_message(1), recipient.session_public_key(), None)
240            .expect("seal short");
241        let longer = seal_message(
242            &backward_message(1_000),
243            recipient.session_public_key(),
244            None,
245        )
246        .expect("seal longer");
247        assert_eq!(short.len(), longer.len());
248    }
249
250    #[test]
251    fn test_cell_round_trip_rejects_wrong_recipient() {
252        let recipient = session();
253        let wrong = session();
254        let message = backward_message(1);
255        let encoded =
256            seal_message(&message, recipient.session_public_key(), None).expect("seal message");
257        let cell: OnionWireCell = rings_codec::deserialize(&encoded).expect("decode cell");
258        assert_eq!(
259            open_cell(&recipient, cell.bucket, &cell.sealed).expect("open cell"),
260            message
261        );
262        assert!(open_cell(&wrong, cell.bucket, &cell.sealed).is_err());
263    }
264
265    #[test]
266    fn test_one_hop_cover_is_authenticated_inside_the_same_cell_algebra() {
267        let recipient = session();
268        let encoded = seal_message(
269            &OnionWireMessage::Cover,
270            recipient.session_public_key(),
271            Some(OnionCellBucket::KiB4),
272        )
273        .expect("seal cover");
274        let cell: OnionWireCell = rings_codec::deserialize(&encoded).expect("decode cover cell");
275
276        assert_eq!(
277            open_cell(&recipient, cell.bucket, &cell.sealed).expect("open cover cell"),
278            OnionWireMessage::Cover
279        );
280        assert_eq!(
281            sealed_cell_bucket(&encoded).expect("read public cell bucket"),
282            OnionCellBucket::KiB4
283        );
284    }
285
286    #[test]
287    fn test_local_bucket_selection_is_minimal_at_every_boundary() {
288        for (index, bucket) in OnionCellBucket::ALL.into_iter().enumerate() {
289            let encoded_capacity = bucket.plaintext_len() - CELL_LENGTH_PREFIX_BYTES;
290            assert_eq!(
291                OnionCellBucket::smallest_for(encoded_capacity).ok(),
292                Some(bucket)
293            );
294            match OnionCellBucket::ALL.get(index + 1).copied() {
295                Some(next) => assert_eq!(
296                    OnionCellBucket::smallest_for(encoded_capacity + 1).ok(),
297                    Some(next)
298                ),
299                None => assert!(OnionCellBucket::smallest_for(encoded_capacity + 1).is_err()),
300            }
301        }
302    }
303}