Skip to main content

onionlink_core/
hs.rs

1use log::{debug, info, warn};
2use rand::seq::SliceRandom;
3
4use crate::circuit::{
5    begin_dir_get_via, connect_guard_circuit, extend_unless_same, Circuit, RelayMessage,
6};
7use crate::crypto::{
8    aes_ctr_crypt, ct_equal, random_bytes, relay_body_len, sha3_256, shake256, tor_mac,
9    x25519_public_from_private, x25519_shared, DigestKind, RelayCrypto,
10};
11use crate::directory::{
12    candidate_rendezvous_relays, link_spec_ipv4, parse_link_specifiers, relay_link_specifiers,
13    relay_usable_rendezvous, same_relay, select_hsdirs, serialize_link_specifiers, HsPeriodKeys,
14    LinkSpecifier, Relay,
15};
16use crate::error::{ensure, err, Result};
17use crate::tor::{
18    K_RELAY_HEADER_LEN, K_RELAY_PAYLOAD_LEN, RELAY_BEGIN, RELAY_CONNECTED, RELAY_DATA, RELAY_END,
19    RELAY_ESTABLISH_RENDEZVOUS, RELAY_INTRODUCE1, RELAY_INTRODUCE_ACK, RELAY_RENDEZVOUS2,
20    RELAY_RENDEZVOUS_ESTABLISHED, RELAY_SENDME,
21};
22use crate::util::{
23    base64_decode, base64_encode_unpadded, from_string, put_u16, put_u64, read_u16, split_ws,
24    to_string_lossy, Bytes,
25};
26use crate::{Consensus, Options};
27
28const K_HS_PROTO: &str = "tor-hs-ntor-curve25519-sha3-256-1";
29
30pub fn extract_pem_message(text: &str, begin_label: &str, end_label: &str) -> Result<String> {
31    let b = text
32        .find(begin_label)
33        .ok_or_else(|| crate::Error::new("PEM begin marker not found"))?;
34    let after_begin = text[b..]
35        .find('\n')
36        .map(|idx| b + idx)
37        .ok_or_else(|| crate::Error::new("bad PEM begin line"))?;
38    let e = text[after_begin..]
39        .find(end_label)
40        .map(|idx| after_begin + idx)
41        .ok_or_else(|| crate::Error::new("PEM end marker not found"))?;
42    Ok(text[after_begin + 1..e].to_string())
43}
44
45pub fn parse_ed25519_cert_subject(pem: &str) -> Result<Bytes> {
46    let cert = base64_decode(&extract_pem_message(
47        pem,
48        "-----BEGIN ED25519 CERT-----",
49        "-----END ED25519 CERT-----",
50    )?)?;
51    ensure(
52        cert.len() >= 1 + 1 + 4 + 1 + 32 + 1 + 64,
53        "short ed25519 cert",
54    )?;
55    ensure(cert[0] == 1, "unsupported ed25519 cert version")?;
56    Ok(cert[7..39].to_vec())
57}
58
59pub fn decrypt_descriptor_layer(
60    ciphertext: &[u8],
61    secret_data: &[u8],
62    subcredential: &[u8],
63    revision: u64,
64    constant: &str,
65) -> Result<Bytes> {
66    ensure(
67        ciphertext.len() >= 16 + 32,
68        "descriptor ciphertext too short",
69    )?;
70    let salt = &ciphertext[..16];
71    let enc = &ciphertext[16..ciphertext.len() - 32];
72    let mac = &ciphertext[ciphertext.len() - 32..];
73    let mut secret_input = secret_data.to_vec();
74    secret_input.extend_from_slice(subcredential);
75    put_u64(&mut secret_input, revision);
76    let mut kdf_in = secret_input;
77    kdf_in.extend_from_slice(salt);
78    kdf_in.extend_from_slice(constant.as_bytes());
79    let keys = shake256(&kdf_in, 32 + 16 + 32);
80    let secret_key = &keys[..32];
81    let secret_iv = &keys[32..48];
82    let mac_key = &keys[48..];
83    let mut mac_in = Bytes::new();
84    put_u64(&mut mac_in, mac_key.len() as u64);
85    mac_in.extend_from_slice(mac_key);
86    put_u64(&mut mac_in, salt.len() as u64);
87    mac_in.extend_from_slice(salt);
88    mac_in.extend_from_slice(enc);
89    ensure(
90        ct_equal(mac, &sha3_256(&mac_in)),
91        "descriptor layer MAC mismatch",
92    )?;
93    let mut plain = aes_ctr_crypt(secret_key, enc, Some(secret_iv))?;
94    while plain.last() == Some(&0) {
95        plain.pop();
96    }
97    Ok(plain)
98}
99
100#[derive(Clone, Debug, Default)]
101pub struct IntroductionPoint {
102    pub links: Vec<LinkSpecifier>,
103    pub ntor_key: Bytes,
104    pub auth_key: Bytes,
105    pub enc_key: Bytes,
106}
107
108#[derive(Clone, Debug, Default)]
109pub struct HiddenServiceDescriptor {
110    pub intros: Vec<IntroductionPoint>,
111}
112
113#[derive(Clone, Debug)]
114pub struct DescriptorFetchResult {
115    pub descriptor: HiddenServiceDescriptor,
116    pub guard: Relay,
117}
118
119pub fn intro_relay_from_descriptor(intro: &IntroductionPoint) -> Result<Relay> {
120    let hp = link_spec_ipv4(&intro.links)
121        .ok_or_else(|| crate::Error::new("intro point lacks IPv4 link specifier"))?;
122    let mut relay = Relay {
123        nickname: "intro".to_string(),
124        ip: hp.host,
125        or_port: hp.port,
126        ntor_key: intro.ntor_key.clone(),
127        ..Relay::default()
128    };
129    for ls in &intro.links {
130        if ls.spec_type == 2 && ls.data.len() == 20 {
131            relay.rsa_id = ls.data.clone();
132        } else if ls.spec_type == 3 && ls.data.len() == 32 {
133            relay.ed_id = ls.data.clone();
134        }
135    }
136    ensure(
137        relay.rsa_id.len() == 20,
138        "intro point lacks RSA identity link specifier",
139    )?;
140    ensure(
141        relay.ed_id.len() == 32,
142        "intro point lacks Ed25519 identity link specifier",
143    )?;
144    ensure(
145        relay.ntor_key.len() == 32,
146        "intro point lacks ntor onion key",
147    )?;
148    Ok(relay)
149}
150
151pub fn parse_inner_descriptor(plain: &str) -> Result<HiddenServiceDescriptor> {
152    let lines: Vec<String> = plain
153        .lines()
154        .map(|line| line.strip_suffix('\r').unwrap_or(line).to_string())
155        .collect();
156    let mut desc = HiddenServiceDescriptor::default();
157    let mut cur: Option<usize> = None;
158    let mut i = 0usize;
159    while i < lines.len() {
160        let line = &lines[i];
161        let parts = split_ws(line);
162        if parts.is_empty() {
163            i += 1;
164            continue;
165        }
166        if parts[0] == "introduction-point" && parts.len() >= 2 {
167            desc.intros.push(IntroductionPoint {
168                links: parse_link_specifiers(&base64_decode(parts[1])?)?,
169                ..IntroductionPoint::default()
170            });
171            cur = Some(desc.intros.len() - 1);
172        } else if parts[0] == "onion-key" && parts.len() >= 3 && parts[1] == "ntor" {
173            if let Some(idx) = cur {
174                desc.intros[idx].ntor_key = base64_decode(parts[2])?;
175            }
176        } else if parts[0] == "enc-key" && parts.len() >= 3 && parts[1] == "ntor" {
177            if let Some(idx) = cur {
178                desc.intros[idx].enc_key = base64_decode(parts[2])?;
179            }
180        } else if parts[0] == "auth-key" {
181            if let Some(idx) = cur {
182                let mut pem = format!("{line}\n");
183                let mut in_cert = false;
184                i += 1;
185                while i < lines.len() {
186                    let cert_line = &lines[i];
187                    pem.push_str(cert_line);
188                    pem.push('\n');
189                    if cert_line == "-----BEGIN ED25519 CERT-----" {
190                        in_cert = true;
191                    }
192                    if cert_line == "-----END ED25519 CERT-----" {
193                        break;
194                    }
195                    i += 1;
196                }
197                ensure(in_cert, "auth-key missing cert")?;
198                desc.intros[idx].auth_key = parse_ed25519_cert_subject(&pem)?;
199            }
200        }
201        i += 1;
202    }
203    desc.intros.retain(|intro| {
204        intro.auth_key.len() == 32
205            && intro.ntor_key.len() == 32
206            && intro.enc_key.len() == 32
207            && link_spec_ipv4(&intro.links).is_some()
208    });
209    ensure(
210        !desc.intros.is_empty(),
211        "descriptor has no usable introduction points",
212    )?;
213    Ok(desc)
214}
215
216pub fn decrypt_hs_descriptor(outer: &str, keys: &HsPeriodKeys) -> Result<HiddenServiceDescriptor> {
217    let mut revision = 0u64;
218    let mut super_pem = String::new();
219    let mut in_super = false;
220    for raw_line in outer.lines() {
221        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
222        let parts = split_ws(line);
223        if parts.len() >= 2 && parts[0] == "revision-counter" {
224            revision = parts[1].parse()?;
225        }
226        if line == "-----BEGIN MESSAGE-----" {
227            in_super = true;
228            super_pem.push_str(line);
229            super_pem.push('\n');
230            continue;
231        }
232        if in_super {
233            super_pem.push_str(line);
234            super_pem.push('\n');
235            if line == "-----END MESSAGE-----" {
236                in_super = false;
237            }
238        }
239    }
240    ensure(
241        revision != 0 || outer.contains("revision-counter 0"),
242        "descriptor missing revision-counter",
243    )?;
244    ensure(
245        !super_pem.is_empty(),
246        "descriptor missing superencrypted blob",
247    )?;
248    let super_cipher = base64_decode(&extract_pem_message(
249        &super_pem,
250        "-----BEGIN MESSAGE-----",
251        "-----END MESSAGE-----",
252    )?)?;
253    let first_plain = decrypt_descriptor_layer(
254        &super_cipher,
255        &keys.blinded,
256        &keys.subcredential,
257        revision,
258        "hsdir-superencrypted-data",
259    )?;
260    let first = to_string_lossy(&first_plain);
261    let inner_pem =
262        extract_pem_message(&first, "-----BEGIN MESSAGE-----", "-----END MESSAGE-----")?;
263    let inner_cipher = base64_decode(&inner_pem)?;
264    let second_plain = decrypt_descriptor_layer(
265        &inner_cipher,
266        &keys.blinded,
267        &keys.subcredential,
268        revision,
269        "hsdir-encrypted-data",
270    )?;
271    parse_inner_descriptor(&to_string_lossy(&second_plain))
272}
273
274#[derive(Clone, Debug, Default)]
275pub struct HsNtorState {
276    pub x: Bytes,
277    pub x_pub: Bytes,
278    pub b: Bytes,
279    pub auth_key: Bytes,
280    pub bx: Bytes,
281    pub ntor_key_seed: Bytes,
282}
283
284#[derive(Clone, Debug, Default)]
285pub struct HsIntroPayload {
286    pub body: Bytes,
287    pub state: HsNtorState,
288}
289
290pub fn build_introduce1(
291    intro: &IntroductionPoint,
292    rp: &Relay,
293    rend_cookie: &[u8],
294    keys: &HsPeriodKeys,
295) -> Result<HsIntroPayload> {
296    ensure(rend_cookie.len() == 20, "bad rendezvous cookie")?;
297    ensure(rp.ntor_key.len() == 32, "rendezvous relay missing ntor key")?;
298    let mut header = vec![0; 20];
299    header.push(2);
300    put_u16(&mut header, 32);
301    header.extend_from_slice(&intro.auth_key);
302    header.push(0);
303
304    let mut plain = rend_cookie.to_vec();
305    plain.push(0);
306    plain.push(1);
307    put_u16(&mut plain, 32);
308    plain.extend_from_slice(&rp.ntor_key);
309    let rp_lspec = serialize_link_specifiers(&relay_link_specifiers(rp)?)?;
310    plain.extend_from_slice(&rp_lspec);
311    if plain.len() < 246 {
312        plain.resize(246, 0);
313    }
314
315    let mut st = HsNtorState::default();
316    st.x = random_bytes(32);
317    st.x_pub = x25519_public_from_private(&st.x)?;
318    st.b = intro.enc_key.clone();
319    st.auth_key = intro.auth_key.clone();
320    st.bx = x25519_shared(&st.x, &st.b)?;
321
322    let proto = from_string(K_HS_PROTO);
323    let mut intro_secret = st.bx.clone();
324    intro_secret.extend_from_slice(&intro.auth_key);
325    intro_secret.extend_from_slice(&st.x_pub);
326    intro_secret.extend_from_slice(&st.b);
327    intro_secret.extend_from_slice(&proto);
328    let mut info = from_string(format!("{K_HS_PROTO}:hs_key_expand"));
329    info.extend_from_slice(&keys.subcredential);
330    let mut kdf_in = intro_secret;
331    kdf_in.extend_from_slice(format!("{K_HS_PROTO}:hs_key_extract").as_bytes());
332    kdf_in.extend_from_slice(&info);
333    let hs_keys = shake256(&kdf_in, 64);
334    let enc_key = &hs_keys[..32];
335    let mac_key = &hs_keys[32..];
336    let encrypted = aes_ctr_crypt(enc_key, &plain, None)?;
337
338    let mut mac_msg = header.clone();
339    mac_msg.extend_from_slice(&st.x_pub);
340    mac_msg.extend_from_slice(&encrypted);
341    let mac = tor_mac(mac_key, &mac_msg);
342
343    let mut body = header;
344    body.extend_from_slice(&st.x_pub);
345    body.extend_from_slice(&encrypted);
346    body.extend_from_slice(&mac);
347    Ok(HsIntroPayload { body, state: st })
348}
349
350pub fn finish_hs_ntor(st: &mut HsNtorState, handshake_info: &[u8]) -> Result<RelayCrypto> {
351    ensure(
352        handshake_info.len() >= 64,
353        "RENDEZVOUS2 handshake too short",
354    )?;
355    let y = &handshake_info[..32];
356    let auth = &handshake_info[32..64];
357    let yx = x25519_shared(&st.x, y)?;
358    let proto = from_string(K_HS_PROTO);
359    let mut secret = yx;
360    secret.extend_from_slice(&st.bx);
361    secret.extend_from_slice(&st.auth_key);
362    secret.extend_from_slice(&st.b);
363    secret.extend_from_slice(&st.x_pub);
364    secret.extend_from_slice(y);
365    secret.extend_from_slice(&proto);
366    let ntor_key_seed = tor_mac(&secret, format!("{K_HS_PROTO}:hs_key_extract").as_bytes());
367    let verify = tor_mac(&secret, format!("{K_HS_PROTO}:hs_verify").as_bytes());
368    let mut auth_input = verify;
369    auth_input.extend_from_slice(&st.auth_key);
370    auth_input.extend_from_slice(&st.b);
371    auth_input.extend_from_slice(y);
372    auth_input.extend_from_slice(&st.x_pub);
373    auth_input.extend_from_slice(&proto);
374    auth_input.extend_from_slice(b"Server");
375    let expected = tor_mac(&auth_input, format!("{K_HS_PROTO}:hs_mac").as_bytes());
376    ensure(
377        ct_equal(auth, &expected),
378        "RENDEZVOUS2 hs-ntor auth mismatch",
379    )?;
380    st.ntor_key_seed = ntor_key_seed.clone();
381    let mut kdf_in = ntor_key_seed;
382    kdf_in.extend_from_slice(format!("{K_HS_PROTO}:hs_key_expand").as_bytes());
383    let k = shake256(&kdf_in, 128);
384    Ok(RelayCrypto::new(
385        &k[..32],
386        &k[32..64],
387        &k[64..96],
388        &k[96..128],
389        DigestKind::Sha3,
390    ))
391}
392
393pub struct RendezvousStream {
394    circ: Circuit,
395    hs: RelayCrypto,
396}
397
398impl RendezvousStream {
399    pub fn new(circ: Circuit, hs_crypto: RelayCrypto) -> Self {
400        Self {
401            circ,
402            hs: hs_crypto,
403        }
404    }
405
406    pub fn begin(&mut self, stream_id: u16, port: u16) -> Result<()> {
407        let mut target = from_string(format!(":{port}"));
408        target.push(0);
409        self.send_hs(RELAY_BEGIN, stream_id, &target)?;
410        loop {
411            let m = self.recv_hs()?;
412            if m.stream_id != stream_id {
413                continue;
414            }
415            if m.cmd == RELAY_CONNECTED {
416                return Ok(());
417            }
418            if m.cmd == RELAY_END {
419                return err("onion service stream ended before CONNECTED");
420            }
421        }
422    }
423
424    pub fn send_data(&mut self, stream_id: u16, data: &[u8]) -> Result<()> {
425        for chunk in data.chunks(K_RELAY_PAYLOAD_LEN) {
426            self.send_hs(RELAY_DATA, stream_id, chunk)?;
427        }
428        Ok(())
429    }
430
431    pub fn read_until_end(&mut self, stream_id: u16, limit: usize) -> Result<Bytes> {
432        let mut out = Bytes::new();
433        let mut circ_window = 1000;
434        let mut stream_window = 500;
435        loop {
436            let m = self.recv_hs()?;
437            if m.stream_id != stream_id {
438                continue;
439            }
440            if m.cmd == RELAY_DATA {
441                out.extend_from_slice(&m.data);
442                circ_window -= 1;
443                if circ_window <= 900 {
444                    self.send_hs(RELAY_SENDME, 0, &[0, 0, 0])?;
445                    circ_window += 100;
446                }
447                stream_window -= 1;
448                if stream_window <= 450 {
449                    self.send_hs(RELAY_SENDME, stream_id, &[])?;
450                    stream_window += 50;
451                }
452                if out.len() > limit {
453                    return err("stream response too large");
454                }
455            } else if m.cmd == RELAY_END {
456                break;
457            }
458        }
459        Ok(out)
460    }
461
462    pub fn end(&mut self, stream_id: u16) -> Result<()> {
463        self.send_hs(RELAY_END, stream_id, &[6])
464    }
465
466    fn send_hs(&mut self, cmd: u8, stream_id: u16, data: &[u8]) -> Result<()> {
467        let body = self.hs.encrypt_relay(cmd, stream_id, data)?;
468        self.circ.send_raw_body(&body)
469    }
470
471    fn recv_hs(&mut self) -> Result<RelayMessage> {
472        loop {
473            let rp_plain = self.circ.recv_raw_body()?;
474            if let Some(body) = self.hs.decrypt_recognized(&rp_plain)? {
475                let len = relay_body_len(&body)?;
476                ensure(
477                    K_RELAY_HEADER_LEN + len <= body.len(),
478                    "relay length too large",
479                )?;
480                return Ok(RelayMessage {
481                    cmd: body[0],
482                    stream_id: read_u16(&body, 3)?,
483                    data: body[K_RELAY_HEADER_LEN..K_RELAY_HEADER_LEN + len].to_vec(),
484                });
485            }
486        }
487    }
488}
489
490pub fn fetch_hidden_service_descriptor(
491    consensus: &Consensus,
492    keys: &HsPeriodKeys,
493    timeout_ms: i32,
494    _verbose: bool,
495) -> Result<DescriptorFetchResult> {
496    let mut srvs = Vec::new();
497    if !consensus.shared_rand_current.is_empty() {
498        srvs.push(consensus.shared_rand_current.clone());
499    }
500    if !consensus.shared_rand_previous.is_empty() {
501        srvs.push(consensus.shared_rand_previous.clone());
502    }
503    ensure(!srvs.is_empty(), "consensus has no shared-rand values")?;
504    let blinded_b64 = base64_encode_unpadded(&keys.blinded);
505    let path = format!("/tor/hs/3/{blinded_b64}");
506    let mut last_error = String::new();
507    let guards = candidate_rendezvous_relays(consensus)?;
508    for srv in srvs {
509        let mut hsdirs = select_hsdirs(
510            consensus,
511            &keys.blinded,
512            &srv,
513            keys.period_num,
514            keys.period_len,
515        )?;
516        hsdirs.shuffle(&mut rand::thread_rng());
517        let mut guard_pos = 0usize;
518        for hsdir in hsdirs {
519            let result: Result<DescriptorFetchResult> = (|| {
520                info!("fetching descriptor from HSDir {}", hsdir.nickname);
521                let mut guard = None;
522                for tries in 0..guards.len() {
523                    let candidate = &guards[(guard_pos + tries) % guards.len()];
524                    if candidate.ed_id != hsdir.ed_id {
525                        guard = Some(candidate.clone());
526                        guard_pos = (guard_pos + tries + 1) % guards.len();
527                        break;
528                    }
529                }
530                let guard = guard
531                    .ok_or_else(|| crate::Error::new("no guard available for HSDir request"))?;
532                let body = begin_dir_get_via(&guard, &hsdir, &path, timeout_ms)?;
533                Ok(DescriptorFetchResult {
534                    descriptor: decrypt_hs_descriptor(&to_string_lossy(&body), keys)?,
535                    guard,
536                })
537            })();
538            match result {
539                Ok(desc) => return Ok(desc),
540                Err(e) => {
541                    last_error = e.to_string();
542                    debug!(
543                        "descriptor fetch from HSDir {} failed: {}",
544                        hsdir.nickname, last_error
545                    );
546                }
547            }
548        }
549    }
550    err(format!(
551        "failed to fetch/decrypt hidden service descriptor: {last_error}"
552    ))
553}
554
555pub fn connect_onion_service(
556    opt: &Options,
557    consensus: &Consensus,
558    desc: &HiddenServiceDescriptor,
559    keys: &HsPeriodKeys,
560    rp: &Relay,
561    guard: &Relay,
562) -> Result<RendezvousStream> {
563    let rend_cookie = random_bytes(20);
564    info!(
565        "connecting to rendezvous point {} at {}:{} via guard {}",
566        rp.nickname, rp.ip, rp.or_port, guard.nickname
567    );
568    let mut rp_circ = connect_guard_circuit(guard, opt.timeout_ms)?;
569    extend_unless_same(&mut rp_circ, guard, rp)?;
570    rp_circ.send_relay(RELAY_ESTABLISH_RENDEZVOUS, 0, &rend_cookie)?;
571    loop {
572        let m = rp_circ.recv_relay()?;
573        if m.cmd == RELAY_RENDEZVOUS_ESTABLISHED {
574            break;
575        }
576    }
577
578    let mut intros = desc.intros.clone();
579    intros.shuffle(&mut rand::thread_rng());
580    let mut last_error = String::new();
581    let mut ntor_state = HsNtorState::default();
582    let mut introduced = false;
583    for intro in intros {
584        let attempt: Result<HsNtorState> = (|| {
585            let intro_relay = intro_relay_from_descriptor(&intro)?;
586            info!(
587                "sending INTRODUCE1 via intro point {}:{}",
588                intro_relay.ip, intro_relay.or_port
589            );
590            let payload = build_introduce1(&intro, rp, &rend_cookie, keys)?;
591            let mut ip_circ = connect_guard_circuit(guard, opt.timeout_ms)?;
592            extend_unless_same(&mut ip_circ, guard, &intro_relay)?;
593            ip_circ.send_relay(RELAY_INTRODUCE1, 0, &payload.body)?;
594            loop {
595                let ack = ip_circ.recv_relay()?;
596                if ack.cmd == RELAY_INTRODUCE_ACK {
597                    ensure(ack.data.len() >= 2, "short INTRODUCE_ACK")?;
598                    let status = read_u16(&ack.data, 0)?;
599                    ensure(status == 0, format!("INTRODUCE_ACK status {status}"))?;
600                    return Ok(payload.state);
601                }
602            }
603        })();
604        match attempt {
605            Ok(state) => {
606                ntor_state = state;
607                introduced = true;
608                break;
609            }
610            Err(e) => last_error = e.to_string(),
611        }
612    }
613    ensure(
614        introduced,
615        format!("all introduction points failed: {last_error}"),
616    )?;
617    info!("waiting for RENDEZVOUS2");
618    let hs_crypto = loop {
619        let m = rp_circ.recv_relay()?;
620        if m.cmd == RELAY_RENDEZVOUS2 {
621            break finish_hs_ntor(&mut ntor_state, &m.data)?;
622        }
623    };
624    let _ = consensus;
625    Ok(RendezvousStream::new(rp_circ, hs_crypto))
626}
627
628pub fn connect_onion_service_with_retries(
629    opt: &Options,
630    consensus: &Consensus,
631    desc: &HiddenServiceDescriptor,
632    keys: &HsPeriodKeys,
633    preferred_guards: &[Relay],
634) -> Result<RendezvousStream> {
635    let candidates = candidate_rendezvous_relays(consensus)?;
636    let mut guards = Vec::<Relay>::new();
637    for g in preferred_guards {
638        if relay_usable_rendezvous(g) && !guards.iter().any(|existing| same_relay(existing, g)) {
639            guards.push(g.clone());
640        }
641    }
642    for g in &candidates {
643        if !guards.iter().any(|existing| same_relay(existing, g)) {
644            guards.push(g.clone());
645        }
646    }
647    ensure(!guards.is_empty(), "no usable guard relays for rendezvous")?;
648    let mut last_error = String::new();
649    let rp_attempts = 12usize.min(candidates.len());
650    let guard_attempts = 3usize.min(guards.len());
651    for i in 0..rp_attempts {
652        for j in 0..guard_attempts {
653            let rp = &candidates[i];
654            let guard = &guards[(i + j) % guards.len()];
655            if same_relay(rp, guard) && guards.len() > 1 {
656                continue;
657            }
658            match connect_onion_service(opt, consensus, desc, keys, rp, guard) {
659                Ok(stream) => return Ok(stream),
660                Err(e) => {
661                    last_error = e.to_string();
662                    warn!("rendezvous attempt failed: {last_error}");
663                }
664            }
665        }
666    }
667    err(format!("all rendezvous attempts failed: {last_error}"))
668}