Skip to main content

nox_crypto/sphinx/
surb.rs

1//! Single Use Reply Blocks (SURBs) for anonymous bidirectional mixnet communication.
2
3use super::lioness::{lioness_decrypt, lioness_encrypt, LionessKeys};
4use super::packet::{PacketError, SphinxPacket};
5use super::{
6    apply_stream_cipher, compute_mac, derive_keys, PathHop, SphinxError, SphinxHeader,
7    ROUTING_INFO_SIZE, SHIFT_SIZE,
8};
9use curve25519_dalek::montgomery::MontgomeryPoint;
10use curve25519_dalek::scalar::Scalar;
11use rand::Rng;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14use x25519_dalek::PublicKey as X25519PublicKey;
15use zeroize::{Zeroize, ZeroizeOnDrop};
16
17/// Removes ISO/IEC 7816-4 padding. Validation prevents cover traffic from consuming SURBs.
18fn unpad_iso7816(data: &[u8]) -> Result<Vec<u8>, SurbError> {
19    super::unpad_iso7816_inner(data).ok_or(SurbError::InvalidPadding)
20}
21
22pub const DEFAULT_POW_DIFFICULTY: u32 = 0;
23
24#[derive(Debug, Error)]
25pub enum SurbError {
26    #[error("Return path is empty")]
27    EmptyPath,
28
29    #[error("Sphinx error: {0}")]
30    Sphinx(#[from] SphinxError),
31
32    #[error("Packet error: {0}")]
33    Packet(#[from] PacketError),
34
35    #[error("Message too large: {size} bytes")]
36    MessageTooLarge { size: usize },
37
38    #[error("Invalid ISO 7816-4 padding in decrypted body")]
39    InvalidPadding,
40
41    #[error("Invalid address at hop {index}: {reason}")]
42    InvalidAddress { index: usize, reason: String },
43}
44
45/// A Single Use Reply Block. Contains a pre-computed return-path header and payload encryption keys.
46/// Each SURB must be used at most once.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Surb {
49    pub id: [u8; 16],
50    pub header: SphinxHeader,
51    pub first_hop: String,
52    pub payload_keys: LionessKeys,
53}
54
55impl Surb {
56    /// Creates a new SURB for the given return path. Returns `(Surb, SurbRecovery)`.
57    pub fn new(
58        path: &[PathHop],
59        id: [u8; 16],
60        pow_difficulty: u32,
61    ) -> Result<(Self, SurbRecovery), SurbError> {
62        if path.is_empty() {
63            return Err(SurbError::EmptyPath);
64        }
65
66        for (i, hop) in path.iter().enumerate() {
67            if hop.address.is_empty() {
68                return Err(SurbError::InvalidAddress {
69                    index: i,
70                    reason: "address is empty".into(),
71                });
72            }
73            if hop.address.len() > 255 {
74                return Err(SurbError::InvalidAddress {
75                    index: i,
76                    reason: format!("address too long ({} bytes, max 255)", hop.address.len()),
77                });
78            }
79        }
80
81        let mut rng = rand::rngs::OsRng;
82
83        // Raw Scalar multiplication (not X25519SecretKey::diffie_hellman) to avoid clamping mismatch
84        let mut ephemeral_public_keys = Vec::with_capacity(path.len());
85        let mut shared_secrets = Vec::with_capacity(path.len());
86        let mut blinding_factors = Vec::with_capacity(path.len());
87
88        let mut current_secret_scalar = Scalar::random(&mut rng);
89        let initial_pk_point =
90            curve25519_dalek::constants::X25519_BASEPOINT * current_secret_scalar;
91        let mut accumulated_blinding = Scalar::ONE;
92
93        for hop in path {
94            let hop_pk_point = MontgomeryPoint(hop.public_key.to_bytes());
95            let shared_point = hop_pk_point * current_secret_scalar;
96            let shared_bytes = shared_point.to_bytes();
97
98            shared_secrets.push(shared_bytes);
99
100            let (_, _, _, blinding) = derive_keys(&shared_bytes);
101            blinding_factors.push(blinding);
102
103            let blinded_point = initial_pk_point * accumulated_blinding;
104            let hop_pk = X25519PublicKey::from(blinded_point.to_bytes());
105            ephemeral_public_keys.push(hop_pk);
106
107            accumulated_blinding *= blinding;
108            current_secret_scalar *= blinding;
109        }
110
111        let mut filler = Vec::new();
112        for secret in shared_secrets.iter().take(path.len().saturating_sub(1)) {
113            let (rho, _, _, _) = derive_keys(secret);
114            let mut keystream = [0u8; ROUTING_INFO_SIZE + SHIFT_SIZE];
115            apply_stream_cipher(&rho, &[0u8; 12], &mut keystream);
116
117            let filler_start_in_keystream = ROUTING_INFO_SIZE - filler.len();
118            for (j, byte) in filler.iter_mut().enumerate() {
119                *byte ^= keystream[filler_start_in_keystream + j];
120            }
121            filler.extend_from_slice(&keystream[ROUTING_INFO_SIZE..ROUTING_INFO_SIZE + SHIFT_SIZE]);
122        }
123
124        if !filler.is_empty() && !path.is_empty() {
125            let (rho, _, _, _) = derive_keys(&shared_secrets[path.len() - 1]);
126            let mut keystream = [0u8; ROUTING_INFO_SIZE];
127            apply_stream_cipher(&rho, &[0u8; 12], &mut keystream);
128            let filler_start = ROUTING_INFO_SIZE - filler.len();
129            for (j, byte) in filler.iter_mut().enumerate() {
130                *byte ^= keystream[filler_start + j];
131            }
132        }
133
134        let mut routing_info = [0u8; ROUTING_INFO_SIZE];
135        let mut next_mac = [0u8; 32];
136        let mut layer_keys: Vec<LionessKeys> = Vec::with_capacity(path.len());
137
138        for i in (0..path.len()).rev() {
139            let shared = shared_secrets[i];
140            let (rho, mu, pi, _) = derive_keys(&shared);
141
142            layer_keys.push(LionessKeys::from_pi(&pi));
143
144            let mut current_routing = [0u8; ROUTING_INFO_SIZE];
145
146            if i == path.len() - 1 {
147                // Final hop (deliver to user)
148                current_routing[0] = 0x01; // Exit flag
149                if !filler.is_empty() {
150                    let filler_start = ROUTING_INFO_SIZE - filler.len();
151                    current_routing[filler_start..].copy_from_slice(&filler);
152                }
153            } else {
154                // Forward hop
155                current_routing[0] = 0x00;
156                let next_addr_bytes = path[i + 1].address.as_bytes();
157                current_routing[1] = next_addr_bytes.len() as u8;
158                current_routing[2..34].copy_from_slice(&next_mac);
159                current_routing[34..34 + next_addr_bytes.len()].copy_from_slice(next_addr_bytes);
160
161                let remainder_len = ROUTING_INFO_SIZE - SHIFT_SIZE;
162                current_routing[SHIFT_SIZE..].copy_from_slice(&routing_info[0..remainder_len]);
163            }
164
165            apply_stream_cipher(&rho, &[0u8; 12], &mut current_routing);
166            let mac = compute_mac(&mu, &current_routing);
167
168            routing_info = current_routing;
169            next_mac = mac;
170        }
171
172        let mut payload_pi = [0u8; 32];
173        rng.fill(&mut payload_pi);
174        let payload_keys = LionessKeys::from_pi(&payload_pi);
175
176        let mut header = SphinxHeader {
177            ephemeral_key: ephemeral_public_keys[0],
178            routing_info,
179            mac: next_mac,
180            nonce: 0,
181        };
182        header.solve_pow(pow_difficulty)?;
183
184        let surb = Surb {
185            id,
186            header,
187            first_hop: path[0].address.clone(),
188            payload_keys: payload_keys.clone(),
189        };
190
191        let recovery = SurbRecovery {
192            id,
193            layer_keys,
194            payload_keys,
195        };
196
197        Ok((surb, recovery))
198    }
199
200    /// Wraps a reply message into a 32KB `SphinxPacket` using this SURB. Called by the service.
201    pub fn encapsulate(&self, message: &[u8]) -> Result<SphinxPacket, SurbError> {
202        use super::packet::PACKET_SIZE;
203        use super::HEADER_SIZE as SPHINX_HEADER_SIZE;
204
205        let body_size = PACKET_SIZE - SPHINX_HEADER_SIZE;
206
207        if message.len() >= body_size {
208            return Err(SurbError::MessageTooLarge {
209                size: message.len(),
210            });
211        }
212
213        let mut body = vec![0u8; body_size];
214        body[..message.len()].copy_from_slice(message);
215        body[message.len()] = 0x80;
216
217        lioness_encrypt(&self.payload_keys, &mut body);
218
219        let packet_bytes = self.header.to_bytes(&body);
220
221        if packet_bytes.len() != PACKET_SIZE {
222            let mut final_packet = vec![0u8; PACKET_SIZE];
223            let copy_len = packet_bytes.len().min(PACKET_SIZE);
224            final_packet[..copy_len].copy_from_slice(&packet_bytes[..copy_len]);
225            return SphinxPacket::from_bytes(final_packet).map_err(SurbError::Packet);
226        }
227
228        SphinxPacket::from_bytes(packet_bytes).map_err(SurbError::Packet)
229    }
230}
231
232/// Recovery keys kept by the SURB sender to decrypt replies. Zeroized on drop.
233#[derive(Debug, Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
234pub struct SurbRecovery {
235    pub id: [u8; 16],
236    /// Per-hop Lioness keys (applied in order to peel onion layers)
237    pub layer_keys: Vec<LionessKeys>,
238    /// Final Lioness keys to decrypt the inner message
239    pub payload_keys: LionessKeys,
240}
241
242impl SurbRecovery {
243    /// Decrypts a reply by peeling onion layers then decrypting the inner payload.
244    pub fn decrypt(&self, encrypted_body: &[u8]) -> Result<Vec<u8>, SurbError> {
245        let mut body = encrypted_body.to_vec();
246
247        // Undo each relay's lioness_decrypt with lioness_encrypt (Lioness is NOT self-inverse)
248        for keys in &self.layer_keys {
249            lioness_encrypt(keys, &mut body);
250        }
251
252        lioness_decrypt(&self.payload_keys, &mut body);
253
254        let unpadded = unpad_iso7816(&body)?;
255
256        Ok(unpadded)
257    }
258
259    /// Decrypts a full `SphinxPacket` reply. Extracts the body at offset 472 (Sphinx header size).
260    pub fn decrypt_packet(&self, packet: &SphinxPacket) -> Result<Vec<u8>, SurbError> {
261        let packet_bytes = packet.as_bytes();
262        let body_start = super::HEADER_SIZE;
263        let encrypted_body = &packet_bytes[body_start..];
264
265        self.decrypt(encrypted_body)
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use x25519_dalek::StaticSecret as X25519SecretKey;
273
274    fn generate_test_path(num_hops: usize) -> (Vec<PathHop>, Vec<X25519SecretKey>) {
275        let mut rng = rand::thread_rng();
276        let secret_keys: Vec<X25519SecretKey> = (0..num_hops)
277            .map(|_| X25519SecretKey::random_from_rng(&mut rng))
278            .collect();
279
280        let path: Vec<PathHop> = secret_keys
281            .iter()
282            .enumerate()
283            .map(|(i, sk)| PathHop {
284                public_key: X25519PublicKey::from(sk),
285                address: format!("node_{}_addr", i),
286            })
287            .collect();
288
289        (path, secret_keys)
290    }
291
292    #[test]
293    fn test_surb_construction() {
294        let (path, _) = generate_test_path(3);
295        let id: [u8; 16] = rand::random();
296
297        let result = Surb::new(&path, id, 0);
298        assert!(result.is_ok());
299
300        let (surb, recovery) = result.unwrap();
301
302        assert_eq!(surb.id, id);
303        assert_eq!(surb.first_hop, "node_0_addr");
304
305        assert_eq!(recovery.id, id);
306        assert_eq!(recovery.layer_keys.len(), 3);
307        assert_eq!(recovery.payload_keys.k1, surb.payload_keys.k1);
308        assert_eq!(recovery.payload_keys.k2, surb.payload_keys.k2);
309        assert_eq!(recovery.payload_keys.k3, surb.payload_keys.k3);
310        assert_eq!(recovery.payload_keys.k4, surb.payload_keys.k4);
311    }
312
313    #[test]
314    fn test_surb_encapsulate() {
315        let (path, _) = generate_test_path(3);
316        let id: [u8; 16] = rand::random();
317
318        let (surb, _recovery) = Surb::new(&path, id, 0).expect("SURB construction failed");
319
320        let message = b"Hello Reply";
321        let result = surb.encapsulate(message);
322        assert!(result.is_ok());
323
324        let packet = result.unwrap();
325        assert_eq!(packet.as_bytes().len(), crate::sphinx::packet::PACKET_SIZE);
326    }
327
328    #[test]
329    fn test_surb_full_roundtrip() {
330        let (path, _secret_keys) = generate_test_path(3);
331        let id: [u8; 16] = rand::random();
332
333        let (surb, recovery) = Surb::new(&path, id, 0).expect("SURB construction failed");
334
335        let message = b"Transaction confirmed: 0x1234567890abcdef";
336        let _packet = surb.encapsulate(message).expect("Encapsulation failed");
337        let body_size = 512;
338        let mut test_body = vec![0u8; body_size];
339        test_body[..message.len()].copy_from_slice(message);
340
341        let original = test_body.clone();
342
343        lioness_encrypt(&surb.payload_keys, &mut test_body);
344        lioness_decrypt(&recovery.payload_keys, &mut test_body);
345
346        assert_eq!(test_body, original);
347    }
348
349    #[test]
350    fn test_surb_empty_path() {
351        let path: Vec<PathHop> = vec![];
352        let id: [u8; 16] = rand::random();
353
354        let result = Surb::new(&path, id, 0);
355        assert!(matches!(result, Err(SurbError::EmptyPath)));
356    }
357
358    #[test]
359    fn test_surb_serialization() {
360        let (path, _) = generate_test_path(2);
361        let id: [u8; 16] = rand::random();
362
363        let (surb, _) = Surb::new(&path, id, 0).expect("SURB construction failed");
364
365        let json = serde_json::to_string(&surb).expect("Serialization failed");
366        let deserialized: Surb = serde_json::from_str(&json).expect("Deserialization failed");
367
368        assert_eq!(deserialized.id, surb.id);
369        assert_eq!(deserialized.first_hop, surb.first_hop);
370        assert_eq!(deserialized.payload_keys.k1, surb.payload_keys.k1);
371        assert_eq!(deserialized.payload_keys.k2, surb.payload_keys.k2);
372    }
373
374    /// Random data should fail ISO 7816-4 padding validation (cover traffic defense).
375    #[test]
376    fn test_decrypt_rejects_random_data() {
377        let (path, _) = generate_test_path(3);
378        let id: [u8; 16] = rand::random();
379
380        let (_, recovery) = Surb::new(&path, id, 0).expect("SURB construction failed");
381
382        let mut rng = rand::thread_rng();
383
384        let random_body: Vec<u8> = (0..32296).map(|_| rand::Rng::gen::<u8>(&mut rng)).collect();
385        let result = recovery.decrypt(&random_body);
386        let mut failures = 0;
387        for _ in 0..100 {
388            let random_data: Vec<u8> = (0..32296).map(|_| rand::Rng::gen::<u8>(&mut rng)).collect();
389            if recovery.decrypt(&random_data).is_err() {
390                failures += 1;
391            }
392        }
393        assert!(
394            failures >= 90,
395            "only {failures}/100 random inputs were rejected -- padding check too permissive",
396        );
397
398        assert!(result.is_err());
399    }
400
401    #[test]
402    fn test_surb_full_roundtrip_with_sphinx() {
403        let (path, secret_keys) = generate_test_path(3);
404        let id: [u8; 16] = rand::random();
405
406        let (surb, recovery) = Surb::new(&path, id, 0).expect("SURB construction failed");
407
408        let message = b"Transaction confirmed: 0x1234567890abcdef";
409        let packet = surb.encapsulate(message).expect("Encapsulation failed");
410
411        let packet_bytes = packet.into_bytes();
412        let (header0, body0) = SphinxHeader::from_bytes(&packet_bytes).expect("Parse failed");
413        let result0 = super::super::into_result(
414            header0
415                .process(&secret_keys[0], body0.to_vec())
416                .expect("Hop 0 failed"),
417        );
418
419        let (next_packet1, body1) = match result0 {
420            super::super::ProcessResult::Forward {
421                next_packet,
422                processed_body,
423                ..
424            } => (next_packet, processed_body),
425            super::super::ProcessResult::Exit { .. } => panic!("Hop 0 should be forward"),
426        };
427
428        let bytes1 = next_packet1.to_bytes(&body1);
429        let (header1, body1_raw) = SphinxHeader::from_bytes(&bytes1).expect("Parse failed");
430        let result1 = super::super::into_result(
431            header1
432                .process(&secret_keys[1], body1_raw.to_vec())
433                .expect("Hop 1 failed"),
434        );
435
436        let (next_packet2, body2) = match result1 {
437            super::super::ProcessResult::Forward {
438                next_packet,
439                processed_body,
440                ..
441            } => (next_packet, processed_body),
442            super::super::ProcessResult::Exit { .. } => panic!("Hop 1 should be forward"),
443        };
444
445        let bytes2 = next_packet2.to_bytes(&body2);
446        let (header2, body2_raw) = SphinxHeader::from_bytes(&bytes2).expect("Parse failed");
447        let result2 = super::super::into_result(
448            header2
449                .process(&secret_keys[2], body2_raw.to_vec())
450                .expect("Hop 2 failed"),
451        );
452
453        let exit_payload = match result2 {
454            super::super::ProcessResult::Exit { payload } => payload,
455            super::super::ProcessResult::Forward { .. } => panic!("Hop 2 should be exit"),
456        };
457
458        let decrypted = recovery
459            .decrypt(&exit_payload)
460            .expect("SURB decryption failed");
461        assert_eq!(decrypted, message);
462    }
463
464    /// Regression: body offset must be 472 (sphinx header), not 1036 (AEAD header + nonce).
465    #[test]
466    fn test_decrypt_packet_correct_offset() {
467        use super::super::packet::PACKET_SIZE;
468
469        let (path, _secret_keys) = generate_test_path(3);
470        let id: [u8; 16] = rand::random();
471
472        let (surb, recovery) = Surb::new(&path, id, 0).expect("SURB construction failed");
473        let message = b"decrypt_packet regression test payload";
474        let packet = surb.encapsulate(message).expect("Encapsulation failed");
475
476        let recovery_no_layers = SurbRecovery {
477            id: recovery.id,
478            layer_keys: vec![],
479            payload_keys: recovery.payload_keys.clone(),
480        };
481
482        let result = recovery_no_layers.decrypt_packet(&packet);
483        assert!(result.is_ok(), "decrypt_packet failed: {:?}", result.err());
484        assert_eq!(result.unwrap(), message.as_slice());
485
486        let packet_bytes = packet.as_bytes();
487        let sphinx_header_size = super::super::HEADER_SIZE;
488        assert_eq!(sphinx_header_size, 472);
489        let body_manual = &packet_bytes[sphinx_header_size..];
490        let manual_result = recovery_no_layers.decrypt(body_manual);
491        assert_eq!(manual_result.unwrap(), message.as_slice());
492
493        assert_eq!(packet_bytes.len(), PACKET_SIZE);
494    }
495
496    /// Guards against regression to the old wrong body offset (1036 instead of 472).
497    #[test]
498    fn test_decrypt_packet_wrong_offset_would_fail() {
499        use super::super::packet::PACKET_SIZE;
500
501        let (path, _secret_keys) = generate_test_path(3);
502        let id: [u8; 16] = rand::random();
503
504        let (surb, recovery) = Surb::new(&path, id, 0).expect("SURB construction failed");
505        let message = b"offset regression test";
506        let packet = surb.encapsulate(message).expect("Encapsulation failed");
507
508        let recovery_no_layers = SurbRecovery {
509            id: recovery.id,
510            layer_keys: vec![],
511            payload_keys: recovery.payload_keys.clone(),
512        };
513
514        let packet_bytes = packet.as_bytes();
515        let wrong_offset = super::super::packet::HEADER_SIZE + super::super::packet::NONCE_SIZE;
516        let correct_offset = super::super::HEADER_SIZE;
517
518        assert_eq!(wrong_offset, 1036);
519        assert_eq!(correct_offset, 472);
520        assert_ne!(wrong_offset, correct_offset);
521
522        let wrong_body = &packet_bytes[wrong_offset..];
523        let wrong_result = recovery_no_layers.decrypt(wrong_body);
524        assert!(
525            wrong_result.is_err(),
526            "offset 1036 unexpectedly decrypted Ok({} bytes)",
527            wrong_result.as_ref().map(|v| v.len()).unwrap_or(0)
528        );
529
530        let correct_body = &packet_bytes[correct_offset..];
531        let correct_result = recovery_no_layers.decrypt(correct_body);
532        assert!(
533            correct_result.is_ok(),
534            "offset 472 failed: {:?}",
535            correct_result.err()
536        );
537        assert_eq!(correct_result.unwrap(), message.as_slice());
538
539        let via_method = recovery_no_layers.decrypt_packet(&packet);
540        assert!(via_method.is_ok());
541        assert_eq!(via_method.unwrap(), message.as_slice());
542
543        assert_eq!(packet_bytes.len(), PACKET_SIZE);
544    }
545
546    #[test]
547    fn test_surb_rejects_empty_address() {
548        let mut rng = rand::thread_rng();
549        let sk = X25519SecretKey::random_from_rng(&mut rng);
550        let pk = X25519PublicKey::from(&sk);
551
552        let path = vec![PathHop {
553            public_key: pk,
554            address: String::new(),
555        }];
556        let id: [u8; 16] = rand::random();
557
558        let result = Surb::new(&path, id, 0);
559        assert!(
560            matches!(result, Err(SurbError::InvalidAddress { index: 0, .. })),
561            "got: {result:?}",
562        );
563    }
564
565    #[test]
566    fn test_surb_rejects_oversized_address() {
567        let mut rng = rand::thread_rng();
568        let sk = X25519SecretKey::random_from_rng(&mut rng);
569        let pk = X25519PublicKey::from(&sk);
570
571        let long_address = "x".repeat(256);
572        let path = vec![PathHop {
573            public_key: pk,
574            address: long_address,
575        }];
576        let id: [u8; 16] = rand::random();
577
578        let result = Surb::new(&path, id, 0);
579        assert!(
580            matches!(result, Err(SurbError::InvalidAddress { index: 0, .. })),
581            "got: {result:?}",
582        );
583    }
584
585    #[test]
586    fn test_unpad_iso7816_valid() {
587        use crate::sphinx::unpad_iso7816_inner;
588
589        // Simple case: "hello" + 0x80 + three 0x00 bytes
590        let mut padded = b"hello".to_vec();
591        padded.push(0x80);
592        padded.extend_from_slice(&[0x00, 0x00, 0x00]);
593
594        let result = unpad_iso7816_inner(&padded);
595        assert_eq!(result, Some(b"hello".to_vec()));
596    }
597
598    #[test]
599    fn test_unpad_iso7816_marker_at_end() {
600        use crate::sphinx::unpad_iso7816_inner;
601
602        let mut padded = b"data".to_vec();
603        padded.push(0x80);
604
605        let result = unpad_iso7816_inner(&padded);
606        assert_eq!(result, Some(b"data".to_vec()));
607    }
608
609    #[test]
610    fn test_unpad_iso7816_invalid_rejected() {
611        use crate::sphinx::unpad_iso7816_inner;
612
613        let corrupted = vec![0x01, 0x02, 0x80, 0xFF];
614        let result = unpad_iso7816_inner(&corrupted);
615        assert_eq!(result, None);
616    }
617
618    #[test]
619    fn test_unpad_iso7816_empty_rejected() {
620        use crate::sphinx::unpad_iso7816_inner;
621
622        let result = unpad_iso7816_inner(&[]);
623        assert_eq!(result, None);
624    }
625
626    #[test]
627    fn test_unpad_iso7816_no_marker_rejected() {
628        use crate::sphinx::unpad_iso7816_inner;
629
630        let no_marker = vec![0x01, 0x02, 0x03, 0x04];
631        let result = unpad_iso7816_inner(&no_marker);
632        assert_eq!(result, None);
633    }
634
635    #[test]
636    fn test_unpad_iso7816_empty_payload() {
637        use crate::sphinx::unpad_iso7816_inner;
638
639        let just_marker = vec![0x80, 0x00, 0x00];
640        let result = unpad_iso7816_inner(&just_marker);
641        assert_eq!(result, Some(vec![]));
642    }
643
644    #[test]
645    fn test_surb_id_hex_roundtrip() {
646        let id: [u8; 16] = rand::random();
647
648        let (path, _) = {
649            let mut rng = rand::thread_rng();
650            let sk = X25519SecretKey::random_from_rng(&mut rng);
651            let pk = X25519PublicKey::from(&sk);
652            let path = vec![PathHop {
653                public_key: pk,
654                address: "node_0".into(),
655            }];
656            (path, ())
657        };
658
659        let (_, recovery) = Surb::new(&path, id, 0).expect("SURB construction must succeed");
660
661        let hex = hex::encode(recovery.id);
662        let decoded = hex::decode(&hex).expect("hex decode");
663        let recovered: [u8; 16] = decoded.try_into().expect("16 bytes");
664
665        assert_eq!(recovery.id, recovered);
666        assert_eq!(hex.len(), 32);
667    }
668}