breakmancer 0.9.0

Drop a breakpoint into any shell.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
//! Support for connecting via Magic Wormhole.
//!
//! # Overview
//!
//! The controller and breakpoint use their shared information (the
//! controller id) to open a Wormhole, and then coordinate from there
//! to open a Transit connection for the main protocol traffic. This
//! will be a direct connection (using STUN) if possible or pass over a
//! TURN-like relay server otherwise.
//!
//! However, breakmancer uses magic-wormhole in several unusual ways:
//!
//! - Rather than a shared secret (a wormhole Code), the controller
//!   and breakpoint use shared *public* information (a Code derived
//!   from the controller ID). This would ordinarily be completely
//!   insecure, but breakmancer has its own encryption protocol, and
//!   uses magic-wormhole only for *connectivity*.
//! - magic-wormhole is a set of two-party protocols, but breakmancer
//!   may have N breakpoints connecting to the same controller...
//!   using the same setup information. It's a little tricky to
//!   combine the two.
//!
//! # Connection happy-path in depth
//!
//! Each new connection session starts with the controller
//! deterministically computing a wormhole Code from the controller
//! ID. It connects to the Nameplate (claiming and allocating it), and
//! the rendezvous server allocates and returns a Mailbox ID. The
//! controller then joins the Mailbox and waits for a breakpoint to do
//! the same.
//!
//! As soon as a breakpoint similarly connects to the Nameplate (but
//! with allocate=false) and joins the Mailbox, the controller and
//! breakpoint's perform the wormhole PAKE handshake and release the
//! Nameplate (all via the magic-wormhole library). The Nameplate is
//! now unallocated and unclaimed, and will not point to the same
//! Mailbox again. The two parties now can exchanged messages over the
//! Mailbox, specifically Transit hints.
//!
//! Once Transit hints are exchanged, the parties open a Transit
//! connection, either directly or via the default Transit relay
//! server. They then close the wormhole connection, releasing the
//! Mailbox (deallocating it and its messages), as it is not needed
//! for the rest of the protocol.
//!
//! All subsequent communication occurs over Transit, which sends
//! message packets. Encoded InsecureMsg messages are sent directly
//! over Transit with no further framing, as Transit takes care of
//! that.
//!
//! # Multiple breakpoints
//!
//! Once the controller finishes with a breakpoint, it drops the
//! Transit connection and again opens a Nameplate -- the same one as
//! before. However, it *should* now receive a different Mailbox, since
//! the Nameplate should have been deallocated.
//!
//! Breakpoints other than the first one will encounter several error
//! conditions that call for a retry:
//!
//! - If the controller has not allocated the Nameplate, the
//!   breakpoint will fail to claim it. This is because the breakpoint
//!   joins with allocate=false. This is an expected condition, and
//!   could either mean that the controller has already exited or that
//!   the controller is currently busy with another breakpoint, having
//!   already moved on to the Mailbox or Transit stages.
//! - If another breakpoint has already claimed the Nameplate, the
//!   later breakpoint will receive a "crowded" error message. This
//!   *should* resolve quickly as the Nameplate to Mailbox progression
//!   should take less than a second.
//!
//! The Nameplate acts as a mutex that allows only two parties in at
//! once, but since one party (the controller) is both unique and
//! privileged, the other "slot" acts as a single-party mutex
//! admitting one breakpoint at a time. It's important that only
//! controllers are privileged to allocate Nameplates, as otherwise we
//! could end up with two breakpoints talking to each other.
//!
//! (Mailboxes also only accept two parties at a time in the default
//! server implementation and will return "crowded" if this would be
//! violated. For discussion on why going directly to a mailbox might
//! be useful see
//! <https://github.com/magic-wormhole/magic-wormhole.rs/issues/307>
//! but note that the lack of an allocate/no-allocate distinction
//! means we can't keep two breakpoints from taking up a mailbox and
//! excluding the controller.)
//!
//! # Dirty disconnects
//!
//! Problems occur when a controller or breakpoint drops a connection
//! and the Nameplate or Mailbox is left in a "dirty" state. The
//! server does not recognize this, and so the broken connection takes
//! up one of the two allowed slots, making the Nameplate or Mailbox
//! unusable. These zombie connections can be caused by a networking
//! issue from either the controller or breakpoint, or by a breakpoint
//! getting killed. (It could also be caused by a controller exiting
//! uncleanly, but in that case all further sessions are impossible
//! anyhow.)
//!
//! - If a Nameplate retains a zombie connection, it will be kept
//!   alive by the server for an indeterminate period of time
//!   (minutes? hours?) and will be unusable. Because the Nameplate is
//!   deterministically generated in breakmancer's usage of
//!   magic-wormhole, every breakpoint will fail from this point
//!   forward (until the server discovers the dead connection).
//!
//! - If a Mailbox has a zombie *after* the Nameplate has been
//!   released, there may not be a problem; the controller should be
//!   able to give up on the connection and re-allocate the Nameplate,
//!   at which point a new Mailbox will be allocated by the server.
//!
//! - If a Mailbox has a zombie *before* the Nameplate is released
//!   (e.g. a breakpoint dies during the PAKE setup) then the
//!   Nameplate will continue to point to the Mailbox until the two
//!   both expire. This is then like the first situation (Nameplate
//!   with a zombie).
//!
//! These error scenarios require a connection failure in a relatively
//! short window of time, so hopefully they shouldn't be too
//! common. We also don't have much in the way of tools for mitigating
//! these issues.
//!
//! # Future ideas
//!
//! ## N-party mailboxes
//!
//! The server protocol spec does not strictly say that mailboxes must
//! be restricted to two parties; this is a detail of the default
//! implementation (likely intended as a sanity check). If this
//! limitation were lifted or bypassed, breakmancer could operate in
//! the following manner:
//!
//! - When a breakpoint joins the mailbox, it announces its ID and
//!   then waits to be called on.
//! - The controller announces which breakpoint it wishes to connect
//!   with and begins exchanging transit hints with it, with the
//!   breakpoint's ID in all messages each direction. If the
//!   breakpoint doesn't respond quickly, the controller assumes it is
//!   dead and moves on. This repeats for each session.
//! - When the controller is exiting, it announces this.
//!
//! There are a few ways this could be achieved:
//!
//! - The server implementation could allow 3+ parties in a mailbox.
//!   The parties would all connect directly to the mailbox, skipping
//!   the nameplate.
//! - Breakmancer could (with some modifications to the client
//!   library) deterministically generate the `side` fields in the API
//!   calls, allowing all of the breakpoints to pretend to be the same
//!   client, albeit one with multiple websocket connections to the
//!   server. This might not require server changes, but would be
//!   fragile if the protocol does not specify that this is allowed.
//!
//! It's not clear how this would all interact with the PAKE setup,
//! though, as that *is* definitively a two-party protocol. Given that
//! breakmancer doesn't even rely on magic-wormhole's encryption,
//! though, it might be possible to just bypass the PAKE messages.
//!
//! ## Reconnects
//!
//! There is some community interest in supporting reconnects in the
//! Rust library, which would involve reusing `side` values across
//! multiple connections. While this would solve the zombie connection
//! problem it could also be creatively misused to allow for N-party
//! mailboxes, as discussed above.
//!
//! ## Code sequence
//!
//! As a last resort for zombie connections, all parties could visit a
//! sequence of up to N Nameplates before giving up.

use std::{borrow::Cow, str::FromStr, sync::Arc, time::Duration};

use blake2::{
    digest::consts::{U32, U8},
    Blake2b, Digest,
};
use magic_wormhole::{
    rendezvous::{RendezvousError, DEFAULT_RENDEZVOUS_SERVER},
    transit::{self, Abilities, Hints, RelayHint, Transit},
    AppConfig, AppID, Code, MailboxConnection, Nameplate, Password, Wormhole, WormholeError,
};
use rust_base58::ToBase58;
use serde::{Deserialize, Serialize};

use crate::protocol::{ControllerId, IdString, Party};

#[derive(Clone, Serialize, Deserialize)]
pub struct WormholeAppVersion {}

pub const APP_CONFIG: AppConfig<WormholeAppVersion> = AppConfig {
    id: AppID(Cow::Borrowed("org.timmc.breakmancer")),
    rendezvous_url: Cow::Borrowed(DEFAULT_RENDEZVOUS_SERVER),
    app_version: WormholeAppVersion {},
};

pub const TRANSIT_KEY_PURPOSE: &str = "org.timmc.breakmancer/main_channel";

type Blake2DeriveNameplate = Blake2b<U8>;
type Blake2DerivePassword = Blake2b<U32>;

/// Derive a wormhole code from a (high entropy!) shared secret.
///
/// The shared secret MUST NOT be a human-derived password or other
/// low-entropy input. If absolutely required, that password or other
/// input should first be run through argon2 or another
/// password-specific hash (with appropriate tuning factors) before
/// feeding it to this function.
///
/// The nameplate and password components of the resulting wormhole
/// code are each derived deterministically from the secret using
/// KDFs.
///
/// If public information is used instead of a secret, the wormhole
/// code can still be used for connectivity, but without any security
/// properties.
fn derive_rendezvous_code_from_secret(shared_secret: &[u8]) -> Code {
    // These context values will ensure the nameplate (public) and
    // password (private) components of the code are derived
    // differently and that one cannot be derived from the other.
    let context_nameplate = String::from("wormhole-derive-code-nameplate");
    let context_password = String::from("wormhole-derive-code-password");

    // The magic-wormhole protocol only defines nameplates as numeric
    // strings, and doesn't set an upper bound. The standard
    // implementation of the server stores them in a SQLite DB,
    // though, so to be on the safe side we should stick to 8 byte
    // numerics.
    //
    // We'll generate a signed 8-byte integer and mask it to
    // non-negative values.
    let mut np_hasher = Blake2DeriveNameplate::new();
    np_hasher.update(format!("{context_nameplate}|").as_bytes());
    np_hasher.update(shared_secret);
    let np_digest = np_hasher.finalize().into();
    let nameplate_int = i64::from_le_bytes(np_digest) & 0x7FFF_FFFF_FFFF_FFFF;
    let nameplate = Nameplate::from_str(&nameplate_int.to_string()).unwrap();

    // The password will need to be derived from a KDF keyed with a
    // different personalization string, since otherwise the nameplate
    // could be converted to the password.
    //
    // We'll make another large high-entropy number but this time will
    // Base58-encode it.
    //
    // Note: This could panic if the digest somehow had low entropy
    // according to magic-wormhole's zxcvbn integration. This would be
    // unlikely, though.
    let mut pass_hasher = Blake2DerivePassword::new();
    pass_hasher.update(format!("{context_password}|").as_bytes());
    pass_hasher.update(shared_secret);
    let pass_digest = pass_hasher.finalize();
    let pass_encoded = pass_digest.to_base58();
    let password = Password::from_str(&pass_encoded)
        .map_err(|err| format!("Could not generate wormhole password from shared secret: {err}"))
        .unwrap();

    Code::from_components(nameplate, password)
}

/// Convert a controller ID to an (insecure) wormhole code.
///
/// NOTE: This is an INSECURE wormhole code; it is only safe to do
/// this in breakmancer because breakmancer has its own encryption
/// layer and does not rely on magic-wormhole for security, only
/// connectivity. (Specifically, note that we're generating a "shared
/// secret" from a value that is probably public in most use-cases.)
///
/// The only goals here are (cross-version) determinism and
/// non-collision.
fn derive_rendezvous_code(controller_id: &ControllerId) -> Code {
    // NOTE: The controller ID is NOT A SECRET, but we were already
    // going to derive a wormhole code from public information; using
    // a function intended to work with secrets isn't going to make
    // things any less secure.
    //
    // (Because security is already at zero, here. We just want
    // connectivity.)
    //
    // Why split this into two functions like this? Because I want to
    // be able to link to the secure version as a reference
    // implementation of "turn a secret into a wormhole code".
    derive_rendezvous_code_from_secret(controller_id.id_string().as_bytes())
}

/// Messages that are sent over the wormhole in order to exchange
/// information for setting up a Transit connection.
#[derive(Clone, Debug, Deserialize, Serialize)]
enum BootstrapMsg {
    /// Bootstrap message sent from the controller to the breakpoint.
    ControllerBootstrap(ControllerBootstrap),
    /// Bootstrap message sent from the breakpoint to the controller.
    BreakpointBootstrap(BreakpointBootstrap),
}

impl BootstrapMsg {
    fn encode(&self) -> Vec<u8> {
        serde_cbor::to_vec(self).unwrap()
    }

    fn decode(raw: &[u8]) -> Result<BootstrapMsg, String> {
        serde_cbor::from_slice(raw)
            .map_err(|err| format!("Could not decode transit bootstrap message: {err}"))
    }
}

/// Shared data structure sent each direction (as part of a message)
/// containing the sending party's Transit abilities and hints.
#[derive(Clone, Debug, Deserialize, Serialize)]
struct TransitNegotiate {
    abilities: Abilities,
    hints: Hints,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
struct ControllerBootstrap {
    transit_negotiate: TransitNegotiate,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
struct BreakpointBootstrap {
    transit_negotiate: TransitNegotiate,
}

/// Use wormhole to coordinate a transit connection. Also closes
/// wormhole.
///
/// This is what the main breakmancer data channel will use.
#[allow(deprecated)] // https://github.com/magic-wormhole/magic-wormhole.rs/issues/306
async fn create_transit(mut wormhole: Wormhole, party: Party) -> Result<Transit, String> {
    // Note that the wormhole key, and therefore the transit key, is
    // *not* derived deterministically from the Password. Instead, the
    // Password is used in a PAKE protocol that derives a shared key,
    // and that involves additional randomness from both parties.
    //
    // This means we don't have to worry about collisions or
    // interference in a situation where multiple breakpoints are
    // trying to use the same key on a Transit relay (e.g. in a
    // reconnect scenario).
    let transit_key = wormhole
        .key()
        .derive_subkey_from_purpose(TRANSIT_KEY_PURPOSE);

    let my_abilities = Abilities::ALL;
    // Use unwrap because the hardcoded default should always work
    let my_relay_hints =
        vec![RelayHint::from_urls(None, [transit::DEFAULT_RELAY_SERVER.parse().unwrap()]).unwrap()];

    let tc = transit::init(my_abilities, None, my_relay_hints.clone())
        .await
        .map_err(|err| format!("Unable to initialize wormhole transit: {err}"))?;

    // Each side sends its own hints and abilities and then waits to
    // receive the same from the other. There's no strict ordering
    // here.

    let transit_negotiate = TransitNegotiate {
        abilities: my_abilities,
        hints: tc.our_hints().as_ref().clone(), // full set of hints
    };
    let my_intro_msg = match party {
        Party::Controller => {
            BootstrapMsg::ControllerBootstrap(ControllerBootstrap { transit_negotiate })
        }
        Party::Breakpoint => {
            BootstrapMsg::BreakpointBootstrap(BreakpointBootstrap { transit_negotiate })
        }
    };
    wormhole
        .send(my_intro_msg.encode())
        .await
        .map_err(|err| format!("Could not send transit intro: {err}"))?;

    let their_intro_raw = wormhole
        .receive()
        .await
        .map_err(|err| format!("Could not receive transit hints: {err}"))?;
    let their_intro_msg = BootstrapMsg::decode(&their_intro_raw)?;
    let their_intro = match (&party, &their_intro_msg) {
        (
            Party::Controller,
            BootstrapMsg::BreakpointBootstrap(BreakpointBootstrap { transit_negotiate }),
        ) => transit_negotiate,
        (
            Party::Breakpoint,
            BootstrapMsg::ControllerBootstrap(ControllerBootstrap { transit_negotiate }),
        ) => transit_negotiate,
        _ => Err(format!(
            "Unexpected message instead of hints: {their_intro_msg:?}"
        ))?,
    };

    // Disconnect from the Mailbox ASAP once we have everything we
    // need for a Transit connection. This might reduce the chance of
    // zombies.
    wormhole
        .close()
        .await
        .map_err(|err| format!("Failed to close wormhole during transit setup: {err}"))?;

    let is_leader = match party {
        Party::Controller => true,
        Party::Breakpoint => false,
    };
    let (transit, transit_info) = tc
        .connect(
            is_leader,
            transit_key,
            their_intro.abilities,
            Arc::new(their_intro.hints.clone()),
        )
        .await
        .map_err(|err| format!("Unable to connect wormhole transit: {err}"))?;
    println!("Connected to wormhole transit: {transit_info}");

    Ok(transit)
}

/// Magic Wormhole controller side, configured for connection but not
/// active on the network until `accept_connection` is called.
pub struct WormholeListener {
    code: Code,
}

impl WormholeListener {
    pub fn new(controller_id: &ControllerId) -> WormholeListener {
        let code = derive_rendezvous_code(controller_id);
        WormholeListener { code }
    }

    // Transport interface

    pub fn describe_listening(&self) -> String {
        String::from("listening via magic-wormhole")
    }

    pub async fn accept_connection(&mut self) -> Result<WormholeTransport, String> {
        // The controller will repeatedly claim (and allocate) the
        // same Nameplate, then open the resulting Mailbox.
        //
        // If there's a zombie connection from a previous session,
        // this might be as far as things get. Errors
        // might include a crowded nameplate or a crowded mailbox.
        let mc = MailboxConnection::connect(APP_CONFIG, self.code.clone(), true)
            .await
            .map_err(|err| format!("Unable to connect to rendezvous server: {err}"))?;

        // This might be a notice of an upcoming maintenance window,
        // request for donations, etc.
        if let Some(motd) = mc.welcome() {
            println!("Message from wormhole server:\n\n{motd}\n");
        }

        // Wait for a breakpoint to join the Mailbox. After this, the
        // Nameplate should be released by the server, leaving it
        // available for another session.
        //
        // Failures during this next part can leave a zombie
        // connection in the Mailbox, rendering the Nameplate unusable
        // until it expires on its own, which can be longer than we're
        // willing to wait.
        let wormhole = Wormhole::connect(mc)
            .await
            .map_err(|err| format!("Unable to connect to wormhole: {err}"))?;

        let transit = create_transit(wormhole, Party::Controller).await?;

        Ok(WormholeTransport {
            data_channel: transit,
        })
    }
}

/// Magic Wormhole breakpoint side, able to make an outbound connection.
pub struct WormholeCaller {
    code: Code,
}

impl WormholeCaller {
    pub fn new(controller_id: &ControllerId) -> WormholeCaller {
        let code = derive_rendezvous_code(controller_id);
        WormholeCaller { code }
    }

    // Transport interface

    pub async fn new_connection(&self) -> Result<WormholeTransport, String> {
        // Claim the Nameplate (once it is allocated) and open the Mailbox.
        //
        // If the controller has already shut down, it will never allocate
        // the Nameplate. We should time out eventually.  However, the
        // controller could just be busy with another breakpoint, so
        // we want to retry for a bit.
        //
        // We could also see a "crowded" error from the Nameplate if
        // another breakpoint is connecting concurrently; this is also
        // an indication we should retry. However, there could be a
        // zombie connection from a previous session, so again we
        // should eventually time out.

        // With these parameters there's about a 30 minute max total
        // wait time.
        let mut attempt = 0;
        let max_attempt = 35;
        let mut wait_s = 0;
        let max_wait_s = 60;

        let mc = loop {
            // There's probably a cleaner way to do this attempt loop...
            if attempt >= max_attempt {
                println!("Made {attempt} attempts, giving up.");
                Err("Gave up on connecting to controller.".to_string())?
            } else {
                attempt += 1;
            }

            wait_s = (wait_s * 2).clamp(1, max_wait_s);

            match MailboxConnection::connect(APP_CONFIG, self.code.clone(), false).await {
                Ok(mc) => break mc,
                Err(WormholeError::UnclaimedNameplate(_)) => {
                    println!(
                        "Controller not currently listening for connections; waiting {wait_s} seconds."
                    );
                    tokio::time::sleep(Duration::from_secs(wait_s)).await;
                    continue;
                }
                Err(WormholeError::ServerError(RendezvousError::Server(err))) => {
                    if &*err == "crowded" {
                        println!(
                            "Wormhole nameplate busy with another breakpoint; waiting {wait_s} seconds."
                        );
                        tokio::time::sleep(Duration::from_secs(wait_s)).await;
                        continue;
                    } else {
                        Err(format!("Unexpected server error: {err}"))?
                    }
                }
                Err(err) => Err(format!("Unexpected error connecting to nameplate: {err}"))?,
            }
        };

        // Finish wormhole handshake with controller, releasing Nameplate.
        let wormhole = Wormhole::connect(mc)
            .await
            .map_err(|err| format!("Unable to connect to wormhole: {err}"))?;

        let transit = create_transit(wormhole, Party::Breakpoint).await?;

        Ok(WormholeTransport {
            data_channel: transit,
        })
    }
}

/// An open Magic Wormhole Transit connection.
pub struct WormholeTransport {
    /// The Transit connection, which might be a direct TCP
    /// connection or might use a Transit relay.
    data_channel: Transit,
}

impl WormholeTransport {
    // Transport interface

    pub async fn receive_raw(&mut self) -> Result<Option<Vec<u8>>, String> {
        // Transit connections don't have a notion of "closed", so we never return Ok(None).
        match self.data_channel.receive_record().await {
            Ok(data) => Ok(Some(data.to_vec())),
            Err(err) => Err(format!("Could not receive data via transit: {err}")),
        }
    }

    pub async fn send_raw(&mut self, data: &[u8]) -> Result<(), String> {
        self.data_channel
            .send_record(data)
            .await
            .map_err(|err| format!("Could not send data packet via transit: {err}"))
    }
}

#[cfg(test)]
mod tests {
    use magic_wormhole::transit::DirectHint;

    use super::*;

    #[test]
    fn code_pinning() {
        let id = ControllerId::from_received_string("34d1MHZZDjhn7SHG7").unwrap();
        assert_eq!(
            derive_rendezvous_code(&id).to_string(),
            "8805933191586387843-4iYM2DsS7Nb6QX7uNwS5r6Ck1AeYofPXM5ghBhy4hvoF"
        );
    }

    #[test]
    fn bootstrap_pinning() {
        let abilities = Abilities::ALL;
        let hints = Hints::new(
            [DirectHint::new("example.com", 12345)],
            [
                RelayHint::from_urls(None, [transit::DEFAULT_RELAY_SERVER.parse().unwrap()])
                    .unwrap(),
            ],
        );
        let msg = BootstrapMsg::ControllerBootstrap(ControllerBootstrap {
            transit_negotiate: TransitNegotiate { abilities, hints },
        });

        assert_eq!(
            msg.encode().escape_ascii().to_string(),
            b"\xa1\
              sControllerBootstrap\
              \xa1\
              qtransit_negotiate\
              \xa2\
              iabilities\x82\xa1dtypemdirect-tcp-v1\
              \xa1\
              dtypehrelay-v1ehints\x82\
              \xa3\
              dtypemdirect-tcp-v1hhostnamekexample.comdport\x1909\
              \xa3\
              dtypehrelay-v1ehints\x81\
              \xa3\
              hhostnamex\x19transit.magic-wormhole.iodport\x19\x0f\
              \xa1\
              dtypemdirect-tcp-v1dname\xf6"
                .escape_ascii()
                .to_string()
        );
    }
}