# magic-wormhole.rs — Repo Map (SPAKE2 pairing)

Source: `docs/research/inspiration/repos/magic-wormhole__magic-wormhole.rs/` (v0.8.1, edition 2024, MSRV 1.92).
Crate: `magic-wormhole` (EUPL-1.2).

## Brief refs: [^28][^29][^34]

See `docs/research/SPT Networked Messaging Research Brief.md` — Magic Wormhole cited for SPAKE2 short-code ceremony, ephemeral mailbox handshake, then direct P2P with transit relay fallback.

## Workspace layout

`Cargo.toml:1-3` — `[workspace] members = [".", "cli"]`, default = `cli`.

- **Library crate** (`./src/`, package `magic-wormhole`) — protocol implementation. Re-exports at `src/lib.rs:40-48`: `MailboxConnection`, `Wormhole`, `Code`, `Password`, `Nameplate`, `AppConfig`, `Wordlist`, `rendezvous`.
- **CLI crate** (`./cli/src/`) — reference consumer; only useful for cribbing the wiring pattern. Not needed at runtime.
- **Crypto deps actually used by pairing** (`Cargo.toml:99-135`): `spake2 = "0.4"`, `crypto_secretbox = "0.1"` (XSalsa20Poly1305), `hkdf = "0.13"`, `sha2 = "0.11"`, `zxcvbn = "3.1"` (password entropy), `serde`, `rand`, `hex`, `url`.
- **Networking** (mailbox only): `async-tungstenite = "0.34"` (smol runtime), `async-net`, `async-io`. **WebSocket**, not raw TCP.
- **Transit-only deps** (gated `feature = "transit"`, `Cargo.toml:178-186`): `noise-protocol`, `noise-rust-crypto`, `socket2`, `stun_codec`, `if-addrs`, `bytecodec`, `async-trait`.

## Integration points for SPT

### Code generation (wordlist, entropy)

- `src/core/wordlist.rs:11-16` — `pub struct Wordlist { num_words, words: Vec<Vec<String>> }`. Two interleaved lists (even/odd) per PGP-words convention so `purple-sausages` alternates pools.
- `src/core/wordlist.rs:104-109` — `Wordlist::default_wordlist(num_words: usize) -> Wordlist`. Loads embedded `pgpwords.json` (256 even + 256 odd words; 8 bits per word).
- `src/core/wordlist.rs:82-97` — `Wordlist::choose_words(&self) -> Password`. `OsRng` + `SliceRandom::choose`; returns `Password::new_unchecked(joined-with-dashes)`.
- `src/core/wordlist.rs:35-53` — `Wordlist::get_completions(&self, prefix) -> Vec<String>`. Tab-completion (optional `fuzzy-complete` feature uses Jaro-Winkler via `fuzzt`; otherwise `starts_with`).
- `src/core/wordlist.rs:112-138` — `load_pgpwords()` deserializes `pgpwords.json`.
- `src/core/wordlist.rs:99-101` — `into_words() -> impl Iterator<Item = String>` (used to feed zxcvbn).
- `src/core.rs:843-880` — `pub struct Code(String)` formatted `"{nameplate}-{password}"`. `Code::from_components`, `nameplate()`, `password()`, `FromStr` parses `N-word-word`.
- `src/core.rs:669-699` — `pub struct Nameplate(String)`; `FromStr` enforces digits-only, non-zero.
- `src/core.rs:743-823` — `pub struct Password { password, entropy }`. `FromStr` rejects `< 4` chars (`ParsePasswordError::TooShort`) or `entropy.guesses() < 2^16` (`LittleEntropy`). Entropy via `zxcvbn::zxcvbn(password, &pgp_words_dict)` at `src/core.rs:775-786`.

### SPAKE2 exchange

- `src/core/key.rs:10` — `use spake2::{Ed25519Group, Identity, Password, Spake2};` (symmetric variant).
- `src/core/key.rs:103-111` — `pub fn make_pake(password: &str, appid: &AppID) -> (Spake2<Ed25519Group>, Vec<u8>)`. Starts symmetric SPAKE2; password = full code string (`"4-purple-sausages"`), identity = appid. Returns state + serialized `PhaseMessage { pake_v1: msg1 }`.
- `src/core/key.rs:148-152` — `pub fn extract_pake_msg(body: &[u8]) -> Result<Vec<u8>, WormholeError>`. Unwraps peer's `PhaseMessage`.
- `src/core.rs:303-313` — driver in `Wormhole::connect`: send pake, receive peer pake, `pake_state.finish(&peer_pake)` → `secretbox::Key`. On `Err`, returns `WormholeError::PakeFailed` (key-confirmation failure = either wrong code or active attacker; only one guess per connection).
- `src/core/key.rs:73-79` — `Key::derive_subkey_from_purpose<NewP>(&self, purpose: &str) -> Key<NewP>` (HKDF-SHA256). Sub-key pattern usable for SPT trust-store keys.
- `src/core/key.rs:193-208` — `derive_key`, `derive_phase_key` (HKDF; `purpose = "wormhole:phase:" + sha256(side) + sha256(phase)`).
- `src/core/key.rs:210-212` — `derive_verifier(key) -> Key`. Both sides can compare for paranoid out-of-band confirmation (`Wormhole::verifier()` at `src/core.rs:455-457`).
- `src/core.rs:269-280` — `pub struct Wormhole { server, phase, key, appid, verifier, our_version, peer_version }`. The post-PAKE handle: `send(Vec<u8>)`, `receive() -> Vec<u8>`, `send_json`, `receive_json`, `close()`. Each `send` derives a fresh per-phase key.

### Mailbox client (rendezvous protocol)

- `src/core/rendezvous.rs:18` — `pub const DEFAULT_RENDEZVOUS_SERVER: &str = "ws://relay.magic-wormhole.io:4000/v1"`. Public, free, no creds required. **Override**: pass a different URL via `AppConfig::rendezvous_url(Cow::Borrowed("wss://your.host/v1"))` (`src/core.rs:529-533`).
- `src/core/rendezvous.rs:348` — `RendezvousServer::connect(appid: &AppID, url: &str) -> Result<(Self, welcome)>`. Opens WebSocket via `async-tungstenite`.
- `src/core/rendezvous.rs:487` — `allocate_claim_open() -> (Nameplate, Mailbox)`. Used by initiator.
- `src/core/rendezvous.rs:519` — `claim_open(Nameplate) -> Mailbox`. Used by responder when joining a known nameplate.
- `src/core/rendezvous.rs:555` — `list_nameplates() -> Vec<Nameplate>`. Used to validate before `claim_open` (`src/core.rs:208-212`).
- `src/core/rendezvous.rs:564` — `release_nameplate()`. Called once both peers attached (`src/core.rs:331-333`).
- `src/core/rendezvous.rs:603` — `shutdown(Mood)`. `Mood` enum at `src/core.rs:481-499` (`Happy`, `Lonely`, `Errory`, `Scared`, `Unwelcome`) — server-side usage stats; harmless if you only ever send `Happy`/`Errory`.
- `src/core.rs:512-534` — `pub struct AppConfig<V> { id: AppID, rendezvous_url: Cow<'static, str>, app_version: V }`. Builder: `.id(...)`, `.rendezvous_url(...)`, `.app_version(...)`.
- `src/core.rs:84-263` — `pub struct MailboxConnection<V>`. Three async constructors:
  - `MailboxConnection::create(config, code_length) -> Self` (`src/core.rs:115-121`) — initiator, server allocates nameplate, words generated locally.
  - `MailboxConnection::create_with_password(config, Password)` (`src/core.rs:145-150`) — initiator with explicit password.
  - `MailboxConnection::connect(config, Code, allocate: bool) -> Self` (`src/core.rs:196-223`) — responder; `allocate=false` fails fast if nameplate vanished.
  - `.code() -> &Code`, `.welcome() -> Option<&str>`, `.shutdown(Mood)`.
- **Self-host**: the mailbox server is a separate project (`magic-wormhole-mailbox-server`, Python; not in this repo). Protocol = WebSocket JSON. AppID scopes traffic — same server can host many apps without crosstalk. Default URL is plain `ws://`; `wss://` requires one of the `tls` / `futures-rustls-*` features (`Cargo.toml:192-198`).

### Transit relay

- `src/transit.rs:41` — `pub const DEFAULT_RELAY_SERVER: &str = "tcp://transit.magic-wormhole.io:4001"`. Plain TCP, not TURN — protocol is a custom encrypted-record stream over a TCP relay (the relay forwards bytes, peer-to-peer encryption via Noise/XSalsa is end-to-end).
- `src/transit.rs:46` — `const PUBLIC_STUN_SERVER: &str = "stun.piegames.de:3478"`. Hard-coded; STUN used only to discover external IP for direct hints.
- `src/transit.rs:143-199` — `pub struct Abilities { direct_tcp_v1, relay_v1, ... }`; consts `Abilities::ALL`, `FORCE_DIRECT` (no relay), `FORCE_RELAY` (no direct, hides your IP).
- `src/transit.rs:443-522` — `pub struct RelayHint { name, tcp: HashSet<DirectHint>, ws: HashSet<Url> }`. `RelayHint::new(name, tcp, ws)` (`:456`), `RelayHint::from_urls(name, urls)` (`:489`) accepts `tcp://`, `ws://`, `wss://`.
- `src/transit.rs:367-389` — `pub struct DirectHint { hostname: String, port: u16 }`.
- `src/transit.rs:729-841` — `pub async fn init(our_abilities, peer_abilities, relay_hints: Vec<RelayHint>) -> Result<TransitConnector, io::Error>`. Binds local sockets, runs STUN, builds `Hints`. Bypass relay = pass empty `relay_hints` *and* `Abilities::FORCE_DIRECT`.
- `src/transit.rs:883-893` — `pub struct TransitConnector { sockets, our_abilities, our_hints: Arc<Hints> }`.
- `src/transit.rs:909-926` — `TransitConnector::connect(role: TransitRole, transit_key, their_abilities, their_hints) -> (Transit, TransitInfo)`. One side `TransitRole::Leader`, other `TransitRole::Follower` (`:869`).
- `src/transit.rs:1371-1386` — `Transit::receive_record`, `send_record(&[u8])`, `flush()`. Length-prefixed, Noise-or-XSalsa-encrypted framed records.
- `src/transit.rs:621-720` — `pub enum ConnectionType { Direct, Relay, ... }`, `pub struct TransitInfo` — exposes what kind of connection won.
- **Transit key derivation**: `Key<WormholeKey>::derive_transit_key(appid) -> Key<TransitKey>` (`src/core/key.rs:45-56`), purpose = `"{appid}/transit-key"`.

### Embedding without file-transfer

`Cargo.toml:176-203`:

```toml
[features]
default       = ["transit", "transfer"]
transfer      = ["transit", "dep:tar", "dep:rmp-serde"]   # file/folder semantics
transit       = ["dep:noise-rust-crypto", "dep:noise-protocol",
                 "dep:socket2", "dep:stun_codec", "dep:if-addrs",
                 "dep:bytecodec", "dep:async-trait"]
forwarding    = ["transit", "dep:rmp-serde", "dep:async-process"]
fuzzy-complete = ["fuzzt"]
```

For a **pairing-only** SPT integration (short-code → SPAKE2 → derive trust-store keys; no NAT-pierced bulk transfer):

```toml
magic-wormhole = { version = "0.8", default-features = false }
```

That keeps only the core mailbox + SPAKE2 path. Drops `tar`, `rmp-serde`, `noise-*`, `socket2`, `stun_codec`, `if-addrs`, `bytecodec`, `async-trait`, `async-process`.

If SPT later wants direct/relay transport for larger blobs, add `features = ["transit"]` and call `transit::init(...)` after `Wormhole::connect`, passing `wormhole.key().derive_transit_key(wormhole.appid())` as the transit key. Add `features = ["transit", "tls", "futures-rustls-platform-verifier"]` (pick one TLS variant) only if using `wss://`.

`wormhole.1` man page (root of repo) documents the CLI surface — useful for protocol-level docs only.

## Examples worth copying

The minimal initiator/responder dance lives in `src/core/test.rs:250-323` (`test_file_rust2rust`). Strip out the `transfer::send` / `request_file` parts and you have the SPT pairing skeleton:

```rust
// Initiator (prints the code, waits for peer)
let mc = MailboxConnection::create(AppConfig { id: AppID::new("spt.pair.v1"),
                                               rendezvous_url: DEFAULT_RENDEZVOUS_SERVER.into(),
                                               app_version: () }, 2).await?;
println!("Pair with: {}", mc.code());
let mut wh = Wormhole::connect(mc).await?;          // performs PAKE
wh.send_json(&our_pubkey_payload).await?;
let their: TheirPubkey = wh.receive_json().await??;
wh.close().await?;

// Responder (user-typed code)
let code: Code = user_input.parse()?;
let mc = MailboxConnection::connect(cfg, code, false).await?;
let mut wh = Wormhole::connect(mc).await?;
let their: TheirPubkey = wh.receive_json().await??;
wh.send_json(&our_pubkey_payload).await?;
wh.close().await?;
```

CLI wiring (relay-hint construction, code parse with entropy fallback, `is_terminal` UX): `cli/src/main.rs:606-727` (`parse_and_connect`). Use as a *taste* reference for ergonomic surface area; do not need at runtime.

## Binary size knobs

- Disable defaults (above) — biggest single win; drops Noise, STUN, tar.
- Drop `fuzzy-complete` (default-off) — `fuzzt` crate.
- `Cargo.toml:205-208`:
  ```toml
  [profile.release]
  overflow-checks = true   # they keep this on; flip to false if you want speed
  strip           = "debuginfo"
  lto             = "thin"
  ```
  Consider `strip = "symbols"`, `lto = "fat"`, `codegen-units = 1`, `panic = "abort"` for SPT-side embedding.
- TLS: leaving all `tls`/`futures-rustls-*` features OFF (which is the default) keeps the build deps-free; only enable if you must connect to `wss://` mailbox/relay.
- `experimental-transfer-v2`, `forwarding`, `all` — leave off.

## Gotchas

- **WebSocket only** for rendezvous (no raw TCP fallback). Default URL is `ws://` (cleartext); the channel is end-to-end PAKE-encrypted regardless, but a censoring middlebox that blocks port 4000 will break pairing. Self-hosting on `wss://443` is the standard workaround — requires picking a TLS feature.
- **`tls` feature is deprecated** (`src/core.rs:286-291`) — it pulls an old `async-tls`. Prefer `futures-rustls-platform-verifier` (modern) or `native-tls` for Windows-native cert store.
- **WASM target** (`cfg(target_family = "wasm")`) uses `ws_stream_wasm` instead of `async-tungstenite` (`src/core/rendezvous.rs:77-82`, `src/transit.rs:87-93`). Not relevant to SPT.
- **Windows native** is supported via `socket2` + `if-addrs` on the transit path. No special Windows code in the pairing/rendezvous core — pure WebSocket, no `libc`, no Unix sockets. Clean fit for SPT's Windows-native constraint.
- **`unsafe` allowed only at well-documented boundaries**: `Password::new_unchecked`, `Nameplate::new_unchecked`. Crate has `#![deny(unsafe_code)]` (`src/lib.rs:23`) with localized `#[expect(unsafe_code)]` waivers — safe to depend on.
- **Single-attempt PAKE**: `WormholeError::PakeFailed` is fatal for a connection. Server records `Mood::Scared`. SPT should surface this as "wrong code or interception attempt — generate a new code" rather than offering retry-with-same-code (that's the whole security model).
- **`zxcvbn` enforces password strength** for `Password::from_str` (`src/core.rs:813-819`): requires ≥ 2^16 guesses. The two-word default code from `Wordlist` (~16 bits) is borderline — `create_with_password` bypasses the check by routing through `create_with_validated_password`. Pay attention if you build codes from custom wordlists.
- **`MySide::generate`** at `src/core.rs:582-590` — 5 bytes of `OsRng` hex-encoded. Acts as the per-connection client ID; each side picks one independently.
- **PGP wordlist is two alternating lists** (even/odd, 256 each). `choose_words` cycles them so an N-word code has guaranteed N×8 = 8N bits of entropy regardless of N. Don't replace with a single flat wordlist without rethinking entropy accounting.
- **License is EUPL-1.2** — copyleft-ish (compatible-licenses list). SPT (presumably MIT/Apache) consuming as a `Cargo.toml` dependency is fine; copying source verbatim into SPT is not.

## Tests to study

- `src/core/test.rs:250-323` — `test_file_rust2rust`: end-to-end two-task happy path with `oneshot::channel` for code handoff. **Closest template for SPT pairing.**
- `src/core/test.rs:75-110` — `test_connect_with_unknown_code_and_allocate_passes` / `..._no_allocate_fails`: shows the `allocate: bool` arg matters for UX (`UnclaimedNameplate` error path).
- `src/core/test.rs:457-499` — `test_wrong_code`: demonstrates `WormholeError::PakeFailed` propagation on both sides; the attacker-or-typo scenario.
- `src/core/test.rs:501-522` — `test_crowded`: third party trying to claim an in-use code → server returns "crowded".
- `src/core/test.rs:524-535` — `test_connect_with_code_expecting_nameplate`: validates parse-time rejection of malformed codes.
- `src/core/wordlist.rs:140-358` — exhaustive coverage of `choose_words`, completion (fuzzy + normal), PGP word loading. Mirror these when stubbing an SPT wordlist override.
- `src/core/test.rs:332-455` — `test_send_many`: pattern for one initiator serving multiple sequential responders on the same code; relevant if SPT ever wants "one pairing code, multiple devices."

## Quick API surface for SPT integrator (cheat sheet)

```rust
use magic_wormhole::{AppConfig, AppID, Code, MailboxConnection, Wormhole,
                     Mood, Wordlist, rendezvous::DEFAULT_RENDEZVOUS_SERVER};

let cfg = AppConfig { id: AppID::new("spt.pair.v1"),
                      rendezvous_url: DEFAULT_RENDEZVOUS_SERVER.into(),
                      app_version: serde_json::json!({"spt": 1}) };

// initiator
let mc   = MailboxConnection::create(cfg.clone(), 2).await?;
let code = mc.code().to_string();                  // print this
let mut wh = Wormhole::connect(mc).await?;         // SPAKE2 here
let verifier_hex = hex::encode(wh.verifier());     // optional OOB check
wh.send_json(&our_pubkey).await?;
let theirs: TrustEntry = wh.receive_json().await??;
wh.close().await?;

// responder
let code: Code = user_typed.parse()?;
let mc   = MailboxConnection::connect(cfg, code, false).await?;
let mut wh = Wormhole::connect(mc).await?;
let theirs: TrustEntry = wh.receive_json().await??;
wh.send_json(&our_pubkey).await?;
wh.close().await?;
```

Default-feature footprint: ~`transit + transfer + tar + noise + stun`. Set `default-features = false` for pairing-only.
