From a8498f32072e395ef88848325a93249d48191dad Mon Sep 17 00:00:00 2001 From: Aavash Date: Sun, 4 Oct 2026 15:44:50 +0200 Subject: [PATCH] feat(sync): local discovery --- docs/sync.md | 101 +++- src-tauri/Cargo.lock | 61 +- src-tauri/Cargo.toml | 3 + src-tauri/Info.plist | 6 + src-tauri/src/lib.rs | 3 + src-tauri/src/sync/mod.rs | 803 ++++++++++++++++++++++++-- src-tauri/src/sync/nearby.rs | 415 +++++++++++++ src-tauri/src/sync/pairing.rs | 394 +++++++++++++ src-tauri/src/sync/protocol.rs | 52 +- src-tauri/src/sync/round.rs | 8 +- src-tauri/src/sync/session.rs | 15 +- src/lib/shell/ConfirmPopover.svelte | 64 +- src/lib/shell/Icon.svelte | 5 + src/lib/shell/icons.ts | 1 + src/lib/shell/settings/SyncTab.svelte | 283 +++++++-- src/lib/state/confirm.svelte.ts | 7 +- src/lib/state/sync-log.test.ts | 7 + src/lib/state/sync-log.ts | 15 + src/lib/state/sync.svelte.ts | 56 +- src/routes/+layout.svelte | 16 +- 20 files changed, 2200 insertions(+), 115 deletions(-) create mode 100644 src-tauri/src/sync/nearby.rs diff --git a/docs/sync.md b/docs/sync.md index 31fe9d6..7d2328f 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -11,21 +11,22 @@ can't read something, it leaves it alone on both devices. ## Modules -| Module | Job | -| ------------- | --------------------------------------------------------------------- | +| Module | Job | +| ------------- | ---------------------------------------------------------------------- | | `manifest.rs` | what a folder holds, what couldn't be read, and what counts as content | -| `plan.rs` | what a round does, for both devices at once (pure) | -| `merge.rs` | three-way merge of a note | -| `preview.rs` | a plan as someone can read it before it runs, and a note's line diff | -| `apply.rs` | carrying out one device's half of a plan, safely | -| `round.rs` | one round, from both ends: the coordinator and the device answering | -| `ids.rs` | repairing two notes that carry one page id, the way the app does | -| `session.rs` | the transport: streams, message limits, silence | -| `protocol.rs` | wire messages | -| `pairing.rs` | pairing codes and claims | -| `state.rs` | identity, paired devices, baselines, lineage, ancestors, spool | -| `mod.rs` | endpoint, peer tasks, Tauri commands | -| `e2e/` | real devices over loopback: scenarios, and seeded random work (fuzz) | +| `plan.rs` | what a round does, for both devices at once (pure) | +| `merge.rs` | three-way merge of a note | +| `preview.rs` | a plan as someone can read it before it runs, and a note's line diff | +| `apply.rs` | carrying out one device's half of a plan, safely | +| `round.rs` | one round, from both ends: the coordinator and the device answering | +| `ids.rs` | repairing two notes that carry one page id, the way the app does | +| `session.rs` | the transport: streams, message limits, silence | +| `protocol.rs` | wire messages | +| `pairing.rs` | pairing codes and claims | +| `nearby.rs` | finding devices on the same network over mDNS | +| `state.rs` | identity, paired devices, baselines, lineage, ancestors, spool | +| `mod.rs` | endpoint, peer tasks, Tauri commands | +| `e2e/` | real devices over loopback: scenarios, and seeded random work (fuzz) | `plan.rs` has no disk or network access (three manifests and two lineages in, operations out), so its rules are tested as cases, and `e2e/` tests them again as whole rounds between real devices. @@ -316,6 +317,47 @@ path. doesn't. Prompts are handled one at a time and recheck the secret, so a second device with the same code is refused as "used or replaced". If a freshly pasted code is refused, the claiming device removes the pairing. +- **Nearby, without a code:** one button under Settings → Sync, **Pair nearby**, pressed on both + devices. It turns on two things on that device, each of which the backend keeps separately: + - **Discoverable** (`sync_set_discoverable`) lets other devices on the network learn this one's + name (`Request::Introduce`) and ask to pair (`Knock`). Off, both are refused + (`not_discoverable`) without a log line. + - **Searching** (`sync_search_nearby`) asks every device heard over mDNS who it is, every + `SEARCH_EVERY`, and lists the discoverable ones. A device that stops being discoverable drops + off the list. + + Neither is saved. Both turn off after `DISCOVERABLE_WINDOW` / `SEARCH_WINDOW` (two minutes), on + Stop, on leaving the Sync page, on a reload or restart, and on both devices once a pairing + succeeds. + + Picking a device on the list starts a three-step exchange (`pairing::Knocks`), and each device + saves the other only once someone has clicked Allow **on it**: + 1. `Knock` carries a commitment to the picker's random nonce; the device picked answers with its + own nonce (`Knocked`). + 2. `Reveal` opens the commitment, and both devices work out the same six **check digits** + (`pairing::check_digits`, from both ids and both nonces). The device picked shows them with + **"Pair with <name>?"**. The picker doesn't show them: it asks for them to be **typed + in**, read off the other device, and counts a wrong number as turning it down + (`digits_mismatch`). The device picked answers `Allowed` once someone clicks Allow there. + 3. `ConfirmKnock` follows once the right digits are typed on the picker and Allow clicked, and + only then does the device picked save it; the picker saves it on `Paired`. A device picked + that already has the picker (its `Paired` was lost last time) goes through all three steps + again and replies `Paired` without saving it twice. A picker never takes `Paired` before + its own prompt is answered. + + So: + - A device that says it's yours can't pair with you on its own say-so: your device asks you + too, and the device you meant shows no prompt at all. + - Names are whatever a device calls itself (cleaned of control and invisible characters, + `pairing::device_name`), so a stranger can take yours. Neither device chose its nonce knowing + the other's, so a device in the middle can't make its digits match yours except by a one in a + million chance. Typing them in, rather than comparing them, means nobody can allow it without + looking. + - Only a discoverable device takes a knock, and only from a device it hears over mDNS. + - Turning one down, or leaving it unanswered, hides the device (ids cost nothing, so a stranger + would just knock again as someone else); pressing Pair nearby again allows one more prompt. + - The pairing code isn't involved and isn't rotated. + - **Allowlist:** iroh authenticates the peer's key before any data is read, so a paired id is allowed and anything else isn't. An unpaired peer can only send a `Claim` (capped at `MAX_CLAIM`). @@ -327,6 +369,37 @@ path. synced folder. A malformed `sync.json` is reported, not regenerated, because regenerating would change the device's identity. +## Devices on the same network + +A device is dialed by its endpoint id. The addresses saved at pairing go stale as soon as either +device changes network, and iroh's own lookup (n0's DNS and relay) needs the internet. So with sync on, +each device also listens over mDNS (`nearby.rs`) for `_set-sync._udp.local`, under its own service +name rather than iroh's shared one so Set only hears from Set, and iroh resolves a paired device's id +from those announcements alongside DNS. Two devices on one Wi-Fi then sync directly, with no internet, +wherever DHCP has moved them. + +- **Nothing more is trusted.** An announcement is an endpoint id and its addresses: a pairing code + without the secret. Being nearby grants nothing; the allowlist and someone clicking Allow are + still the only ways in (see Pairing and trust). +- **What it reveals.** A device announces itself only when someone could want to find it: it has a + paired device, or it is discoverable or searching (`Inner::announcing`). Pairing, unpairing and + either switch change this while sync runs, by swapping in a new mDNS lookup (`nearby::Lookup`) + rather than restarting the endpoint. A + new lookup hears everyone again, so the devices the old one heard are forgotten (it can't say + which of them left). Then anyone on the network can see that a Set + device is there and its endpoint id, which is stable, so the device can be recognised across + networks. The device name isn't in the announcement; another Set device can learn it only while + this one is discoverable. +- **Bounded.** At most `MAX_HEARD` unpaired devices are tracked, and replies from unpaired devices + are read up to `MAX_CLAIM` (`session::request_small`), so a host announcing many ids can't make + a search dial or read without limit. +- **Optional.** Where the network allows no multicast, or macOS hasn't been allowed Local Network + access (`NSLocalNetworkUsageDescription` in `Info.plist`; refused sends fail with "No route to + host"), the lookup is logged as `sync.mdns_unavailable` or simply hears nothing, and sync goes + through the relay as before. +- **Shown.** A paired device announcing itself is marked `nearby` in its status, and Settings → Sync + says "on this network" beside it. + ## Transport - Incoming streams are each served on their own task (`session::serve_streams`). At most `IN_FLIGHT` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1e0fe05..9f79ffa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acto" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598381761ee991bf2f1455f700380e2191fb370dc9df1ee764f348b7f089d8b6" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str", + "sync_wrapper", + "tokio", + "tracing", +] + [[package]] name = "adler2" version = "2.0.1" @@ -1117,7 +1132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 3.0.3", ] [[package]] @@ -2730,6 +2745,26 @@ dependencies = [ "url", ] +[[package]] +name = "iroh-mdns-address-lookup" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7afb03c296022fcb724a7bb76dbb8dcca6aac4f4eb65df161e084b7276c93d8c" +dependencies = [ + "data-encoding", + "derive_more", + "futures-util", + "iroh", + "iroh-base", + "n0-error", + "n0-future", + "n0-watcher", + "swarm-discovery", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "iroh-metrics" version = "1.0.1" @@ -5153,6 +5188,7 @@ dependencies = [ "cpal", "diffy", "iroh", + "iroh-mdns-address-lookup", "iroh-tickets", "notify", "postcard", @@ -5169,6 +5205,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tokio", + "tokio-stream", "unicode-normalization", "ureq", "uuid", @@ -5292,6 +5329,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + [[package]] name = "socket2" version = "0.6.4" @@ -5455,6 +5498,21 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swarm-discovery" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b4877f20f064de34b77d79237c9caa37c393590af8ef5a2f3253d59be5a338b" +dependencies = [ + "acto", + "hickory-proto", + "rand", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -5473,7 +5531,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 932b9f0..c759feb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,6 +60,9 @@ iroh = "1" # pair mismatched lines. Fixed in Cargo.lock; if `cargo update` brings it back, `cargo update -p # wmi`. scripts/check-lockfile.mjs guards it. +# Paired devices on the same network, found without the relay (src/sync/nearby.rs). +iroh-mdns-address-lookup = "0.6" +tokio-stream = "0.1" # `EndpointTicket`: endpoint id plus how to reach it, as one pasteable string. iroh-tickets = "1" postcard = { version = "1", features = ["alloc"] } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 770b2d0..c94260f 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -4,5 +4,11 @@ NSMicrophoneUsageDescription Set uses the microphone for dictation. Your voice is transcribed on this Mac and never leaves it. + NSLocalNetworkUsageDescription + Set finds your paired devices on this network so it can sync with them directly. + NSBonjourServices + + _set-sync._udp + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8edee4a..5633425 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -324,6 +324,9 @@ pub fn run() { sync::sync_regenerate_pairing_code, sync::sync_answer_pair, sync::sync_pair, + sync::sync_search_nearby, + sync::sync_set_discoverable, + sync::sync_pair_nearby, sync::sync_unpair, sync::sync_now, sync::sync_answer_preview, diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 24f79a3..3302470 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -3,6 +3,7 @@ pub mod ids; pub mod log; pub mod manifest; pub mod merge; +pub mod nearby; pub mod pairing; pub mod plan; pub mod preview; @@ -22,6 +23,7 @@ use std::time::Duration; use iroh::endpoint::Connection; use iroh::protocol::{AcceptError, ProtocolHandler, Router}; use iroh::{Endpoint, EndpointAddr, EndpointId}; +use iroh_mdns_address_lookup::MdnsAddressLookup; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; @@ -31,6 +33,8 @@ use state::Config; /// How long "Sync now" waits to reach a device before reporting it offline. const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +const INTRODUCE_TIMEOUT: Duration = Duration::from_secs(5); + /// Both devices' round locks are held while a preview is up. const REVIEW_TIMEOUT: Duration = Duration::from_secs(10 * 60); @@ -47,6 +51,10 @@ pub struct Status { pub pairing_code: Option, pub peers: Vec, + pub discoverable: bool, + /// Milliseconds since the epoch. + pub searching_until: Option, + pub nearby: Vec, pub last_error: Option, } @@ -57,6 +65,7 @@ pub struct PeerStatus { pub id: String, pub name: String, pub connected: bool, + pub nearby: bool, pub syncing: bool, pub last_synced_at: Option, @@ -68,6 +77,14 @@ pub struct PeerStatus { pub too_big: Vec, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NearbyDevice { + pub id: String, + pub name: String, + pub compatible: bool, +} + #[derive(Clone, Debug, Serialize)] pub struct TooBigFile { pub path: String, @@ -110,6 +127,8 @@ struct Inner { notes_root: Mutex>, peers: Mutex>, + nearby: Mutex, + knocks: Mutex, last_error: Mutex>, running: tokio::sync::Mutex>, @@ -123,7 +142,7 @@ struct Inner { approving: tokio::sync::Mutex<()>, /// The prompt on screen, if any, and where its answer goes. - approval: Mutex)>>, + approval: Mutex)>>, /// The preview on screen, if any. One at most: a round is what shows it, /// and `round_lock` lets one round run at a time. @@ -133,6 +152,13 @@ struct Inner { struct Running { endpoint: Endpoint, router: Router, + /// `None` where the network allows no multicast. + mdns: Option, +} + +struct Mdns { + lookup: nearby::Lookup, + watch: tauri::async_runtime::JoinHandle<()>, } impl Sync { @@ -152,6 +178,8 @@ impl Sync { config: Mutex::new(config), notes_root: Mutex::new(None), peers: Mutex::new(HashMap::new()), + nearby: Mutex::new(nearby::Nearby::default()), + knocks: Mutex::new(pairing::Knocks::default()), last_error: Mutex::new(error), running: tokio::sync::Mutex::new(None), round_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -180,6 +208,9 @@ impl Inner { let config = self.config(); let code = self.pairing_code(endpoint); let live = self.peers.lock().expect("sync peers poisoned"); + let nearby = self.nearby.lock().expect("sync nearby poisoned"); + let now = now_ms(); + let searching = nearby.searching(now); Status { enabled: config.enabled, running: endpoint.is_some(), @@ -197,6 +228,7 @@ impl Inner { id: d.id.to_string(), name: d.name.clone(), connected: l.is_some_and(|l| l.connected), + nearby: nearby.contains(&d.id), syncing: l.is_some_and(|l| l.syncing), last_synced_at: l.and_then(|l| l.last_synced_at), last_error: l.and_then(|l| l.last_error.clone()), @@ -213,6 +245,23 @@ impl Inner { } }) .collect(), + discoverable: nearby.discoverable(now), + searching_until: searching.map(|s| s.until), + nearby: if searching.is_some() { + let mut devices: Vec = nearby + .introduced() + .filter(|(id, _)| !config.is_paired(id)) + .map(|(id, intro)| NearbyDevice { + id: id.to_string(), + name: intro.name.clone(), + compatible: intro.version == protocol::VERSION, + }) + .collect(); + devices.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id))); + devices + } else { + Vec::new() + }, last_error: self.last_error.lock().expect("sync error poisoned").clone(), } } @@ -267,6 +316,10 @@ impl Inner { .iter() .find(|d| d.id == *peer) .map(|d| d.name.clone()) + .or_else(|| { + let nearby = self.nearby.lock().expect("sync nearby poisoned"); + nearby.name(peer).map(str::to_string) + }) .unwrap_or_else(|| format!("device {}", &peer.to_string()[..8])) } } @@ -279,12 +332,14 @@ impl Inner { } let config = self.config(); - let endpoint = match Endpoint::builder(iroh::endpoint::presets::N0) + let mdns = nearby::Lookup::new(config.endpoint_id(), self.announcing()); + let mut builder = Endpoint::builder(iroh::endpoint::presets::N0) .secret_key(config.secret_key.clone()) - .alpns(vec![protocol::ALPN.to_vec()]) - .bind() - .await - { + .alpns(vec![protocol::ALPN.to_vec()]); + if let Some(mdns) = &mdns { + builder = builder.address_lookup(mdns.clone()); + } + let endpoint = match builder.bind().await { Ok(endpoint) => endpoint, Err(e) => { let msg = format!("starting sync: {e}"); @@ -297,7 +352,16 @@ impl Inner { .accept(protocol::ALPN, Handler(Arc::downgrade(self))) .spawn(); - *running = Some(Running { endpoint, router }); + let mdns = mdns.map(|lookup| Mdns { + watch: self.watch(lookup.mdns()), + lookup, + }); + + *running = Some(Running { + endpoint, + router, + mdns, + }); drop(running); // A round cut off by a crash left files staged under hidden names. @@ -323,16 +387,68 @@ impl Inner { let taken = self.running.lock().await.take(); let was_running = taken.is_some(); if let Some(running) = taken { + if let Some(mdns) = running.mdns { + mdns.watch.abort(); + } let _ = running.router.shutdown().await; running.endpoint.close().await; } self.peers.lock().expect("sync peers poisoned").clear(); + self.nearby.lock().expect("sync nearby poisoned").reset(); + *self.knocks.lock().expect("sync knocks poisoned") = pairing::Knocks::default(); if was_running { log::info("sync.stopped").emit(); } self.broadcast().await; } + fn watch(self: &Arc, mdns: MdnsAddressLookup) -> tauri::async_runtime::JoinHandle<()> { + let weak = Arc::downgrade(self); + tauri::async_runtime::spawn(nearby::watch(mdns, move |event| { + let Some(inner) = weak.upgrade() else { return }; + let config = inner.config(); + let shown = inner + .nearby + .lock() + .expect("sync nearby poisoned") + .apply(event, |id| config.is_paired(id)); + if shown { + tauri::async_runtime::spawn(async move { inner.broadcast().await }); + } + })) + } + + /// Only when someone could want to find this device: anyone on the network sees the + /// announcement, and its endpoint id never changes. + fn announcing(&self) -> bool { + let now = now_ms(); + let nearby = self.nearby.lock().expect("sync nearby poisoned"); + !self.config().paired.is_empty() + || nearby.discoverable(now) + || nearby.searching(now).is_some() + } + + /// The endpoint stays up, so nothing in progress is cut off. + async fn announce(self: &Arc) -> Result<(), String> { + let mut running = self.running.lock().await; + let running = running + .as_mut() + .ok_or_else(|| "turn on sync first".to_string())?; + let Some(mdns) = running.mdns.as_mut() else { + return Ok(()); + }; + if let Some(swapped) = mdns.lookup.advertise(self.announcing()) { + mdns.watch.abort(); + // The old mDNS can no longer say who left, and the new one hears everyone again. + self.nearby + .lock() + .expect("sync nearby poisoned") + .forget_devices(); + mdns.watch = self.watch(swapped); + } + Ok(()) + } + async fn restart(self: &Arc) -> Result<(), String> { self.stop().await; if self.config().enabled { @@ -393,6 +509,129 @@ impl Inner { self.broadcast().await; } + async fn introduce(self: &Arc, endpoint: &Endpoint, peer: EndpointId) { + if self.config().is_paired(&peer) { + return; + } + let asked = tokio::time::timeout(INTRODUCE_TIMEOUT, async { + let connection = endpoint + .connect(EndpointAddr::new(peer), protocol::ALPN) + .await + .map_err(|e| e.to_string())?; + let reply = session::request_small(&connection, &Request::Introduce).await; + connection.close(0u32.into(), b"introduced"); + reply + }) + .await; + let heard = match asked { + Ok(Ok(Response::Introduced { + version, + device_name, + })) => Some(nearby::Introduction { + name: pairing::device_name(&device_name, &peer), + version, + }), + // Refused, or an older Set that doesn't know the question: not discoverable. + Ok(Ok(_)) => None, + _ => return, + }; + let changed = self + .nearby + .lock() + .expect("sync nearby poisoned") + .heard(&peer, heard); + if changed { + self.broadcast().await; + } + } + + /// Asks every device each time round, so one made discoverable meanwhile turns up and one no + /// longer discoverable drops off. + async fn search(self: &Arc) -> Result<(), String> { + let endpoint = self.endpoint().await?; + let search = self + .nearby + .lock() + .expect("sync nearby poisoned") + .search(now_ms()); + self.announce().await?; + log::info("nearby.searching").emit(); + self.broadcast().await; + + let inner = Arc::downgrade(self); + tauri::async_runtime::spawn(async move { + loop { + let Some(inner) = inner.upgrade() else { return }; + let (still, devices) = { + let nearby = inner.nearby.lock().expect("sync nearby poisoned"); + let still = nearby.searching(now_ms()).map(|s| s.id) == Some(search.id); + (still, nearby.devices()) + }; + if !still { + inner.stop_search(Some(search.id)).await; + return; + } + let mut asking = tokio::task::JoinSet::new(); + for peer in devices { + let inner = inner.clone(); + let endpoint = endpoint.clone(); + asking.spawn(async move { inner.introduce(&endpoint, peer).await }); + } + drop(inner); + asking.join_all().await; + tokio::time::sleep(nearby::SEARCH_EVERY).await; + } + }); + Ok(()) + } + + async fn stop_search(self: &Arc, id: Option) { + let stopped = self + .nearby + .lock() + .expect("sync nearby poisoned") + .stop_search(id); + if stopped { + let _ = self.announce().await; + self.broadcast().await; + } + } + + async fn discover(self: &Arc) -> Result<(), String> { + self.endpoint().await?; + let window = self + .nearby + .lock() + .expect("sync nearby poisoned") + .discover(now_ms()); + self.announce().await?; + log::info("nearby.discoverable").emit(); + self.broadcast().await; + + let inner = Arc::downgrade(self); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(nearby::DISCOVERABLE_WINDOW).await; + if let Some(inner) = inner.upgrade() { + inner.hide(Some(window.id)).await; + } + }); + Ok(()) + } + + async fn close_nearby(self: &Arc) { + self.hide(None).await; + self.stop_search(None).await; + } + + async fn hide(self: &Arc, id: Option) { + let hidden = self.nearby.lock().expect("sync nearby poisoned").hide(id); + if hidden { + log::info("nearby.hidden").emit(); + let _ = self.announce().await; + self.broadcast().await; + } + } + /// Dials a paired device and redeems its pairing claim if one is pending. async fn connect( self: &Arc, @@ -481,18 +720,7 @@ impl Inner { .emit(); None } - Ok(Response::Refused { code, message }) => Some((code, message)), - - Ok(Response::Error(e)) => Some(("peer_error".to_string(), e)), - - Ok(ref other) => Some(( - "unexpected_reply".to_string(), - format!( - "the other device didn't understand this pairing request. The two are \ - probably running different versions of Set. Reply was {other:?}" - ), - )), - + Ok(other) => Some(refusal(other)), Err(_) => return Ok(()), }; let Some((reason, error)) = outcome else { @@ -851,6 +1079,29 @@ impl Inner { ) .await; } + match &request { + Request::Introduce => return self.introduce_self(peer), + Request::Knock { + version, + device_name, + notes_id, + commit, + } => { + return self + .knocked( + peer, + connection, + *version, + device_name, + notes_id.as_deref(), + commit, + ) + .await + } + Request::Reveal { nonce } => return self.revealed(peer, connection, nonce).await, + Request::ConfirmKnock => return self.confirmed(peer, connection).await, + _ => {} + } let paired = self.config().is_paired(&peer); if !paired { @@ -908,25 +1159,336 @@ impl Inner { return self.refuse(peer, refused).await; } - let name = match device_name.trim() { - "" => format!("Device {}", &peer.to_string()[..8]), - named => named.to_string(), - }; + let name = pairing::device_name(device_name, &peer); // Whoever went first may have used the code up; check again once it is our turn. let _turn = self.approving.lock().await; if !self.config().pairing_secret.same_as(secret) { return self.refuse(peer, pairing::Refused::Wrong).await; } - match self.ask(connection, &name).await { - Some(true) => {} - Some(false) => { + match self.ask(connection, &name, Prompt::Code).await { + Some(Answer::Allowed) => {} + Some(_) => { self.use_up_code(); return self.refuse(peer, pairing::Refused::Declined).await; } None => return self.refuse(peer, pairing::Refused::NoAnswer).await, } + self.admit(peer, name, notes_id, now, true).await + } + + /// Asked every few seconds by every searching device, so refused without a log line. + fn open_to(&self, peer: &EndpointId) -> Result<(), Response> { + let open = self + .nearby + .lock() + .expect("sync nearby poisoned") + .open_to(peer, now_ms()); + if open { + return Ok(()); + } + Err(Response::Refused { + code: pairing::Refused::NotDiscoverable.code().to_string(), + message: pairing::Refused::NotDiscoverable.to_string(), + }) + } + + fn introduce_self(&self, peer: EndpointId) -> Response { + if let Err(refused) = self.open_to(&peer) { + return refused; + } + Response::Introduced { + version: protocol::VERSION, + device_name: self.config().device_name, + } + } + + async fn knocked( + self: &Arc, + peer: EndpointId, + connection: &Connection, + version: u32, + device_name: &str, + notes_id: Option<&str>, + commit: &[u8; 32], + ) -> Response { + // A device already paired here still takes every step: its `Paired` may have been lost, + // and it must not skip its own prompt on our word (`admit` doesn't save it twice). + if let Err(refused) = self.open_to(&peer) { + return refused; + } + if let Err(refused) = pairing::may_claim(version) { + return self.refuse(peer, refused).await; + } + let now = now_ms(); + let allowed = self + .throttle + .lock() + .expect("pairing throttle poisoned") + .allows(&peer, now); + if let Err(refused) = allowed { + return self.refuse(peer, refused).await; + } + if let Err(refused) = self.joining(peer, notes_id) { + return self.refuse(peer, refused).await; + } + let nonce = self.knocks.lock().expect("sync knocks poisoned").knock( + peer, + connection.stable_id(), + pairing::device_name(device_name, &peer), + notes_id.map(str::to_string), + *commit, + now, + ); + Response::Knocked { nonce } + } + + async fn revealed( + self: &Arc, + peer: EndpointId, + connection: &Connection, + theirs: &pairing::Nonce, + ) -> Response { + if let Err(refused) = self.open_to(&peer) { + return refused; + } + let revealed = self.knocks.lock().expect("sync knocks poisoned").reveal( + &peer, + connection.stable_id(), + theirs, + now_ms(), + ); + let revealed = match revealed { + Ok(revealed) => revealed, + Err(refused) => return self.refuse(peer, refused).await, + }; + let me = self.config().endpoint_id(); + let digits = pairing::check_digits(&peer, &me, theirs, &revealed.nonce); + // Not waiting for a turn: two devices knocking on each other would each wait on the other. + let Ok(_turn) = self.approving.try_lock() else { + self.knocks + .lock() + .expect("sync knocks poisoned") + .forget(&peer); + return self.refuse(peer, pairing::Refused::Busy).await; + }; + let prompt = Prompt::Nearby { + digits: &digits, + asking: false, + }; + let answer = self.ask(connection, &revealed.name, prompt).await; + if answer == Some(Answer::Allowed) { + let allowed = self.knocks.lock().expect("sync knocks poisoned").allowed( + &peer, + connection.stable_id(), + now_ms(), + ); + return match allowed { + Ok(()) => Response::Allowed, + Err(refused) => self.refuse(peer, refused).await, + }; + } + + self.knocks + .lock() + .expect("sync knocks poisoned") + .forget(&peer); + let refused = { + let mut throttle = self.throttle.lock().expect("pairing throttle poisoned"); + match answer { + Some(_) => { + throttle.block(peer, now_ms()); + pairing::Refused::DeclinedNearby + } + None => { + throttle.failed(peer, now_ms()); + pairing::Refused::NoAnswer + } + } + }; + // Ids cost nothing, so a stranger turned down would just knock again as someone else. + // Hiding means one prompt per press of Pair nearby. + self.hide(None).await; + self.refuse(peer, refused).await + } + + async fn confirmed(self: &Arc, peer: EndpointId, connection: &Connection) -> Response { + let confirmed = self.knocks.lock().expect("sync knocks poisoned").confirm( + &peer, + connection.stable_id(), + now_ms(), + ); + match confirmed { + Ok((name, notes_id)) => { + self.admit(peer, name, notes_id.as_deref(), now_ms(), false) + .await + } + Err(refused) => self.refuse(peer, refused).await, + } + } + + async fn pair_nearby(self: &Arc, peer: EndpointId) -> Result<(), String> { + if self.config().is_paired(&peer) { + return Err("that device is already paired".to_string()); + } + let name = self + .nearby + .lock() + .expect("sync nearby poisoned") + .name(&peer) + .map(str::to_string) + .ok_or_else(|| { + "that device isn't on the list anymore. Click Find nearby devices again".to_string() + })?; + let endpoint = self.endpoint().await?; + let Ok(_turn) = self.approving.try_lock() else { + return Err("answer the pairing prompt that's already open first".to_string()); + }; + + let connection = match tokio::time::timeout( + CONNECT_TIMEOUT, + endpoint.connect(EndpointAddr::new(peer), protocol::ALPN), + ) + .await + { + Ok(Ok(connection)) => connection, + Ok(Err(e)) => return Err(format!("couldn't reach {name}: {e}")), + Err(_) => return Err(format!("couldn't reach {name}. Is Set open on it?")), + }; + let knocked = self.knock(&connection, peer, &name).await; + connection.close(0u32.into(), b"paired"); + + let notes_id = match knocked { + Ok(notes_id) => notes_id, + Err((code, message)) => { + log::error("pair.rejected") + .peer(peer, &name) + .field("by", "nearby") + .field("reason", &code) + .field("error", &message) + .emit(); + return Err(message); + } + }; + { + let mut config = self.config.lock().expect("sync config poisoned"); + if !config.is_paired(&peer) { + // Only once saved: unsaved, a retry would be turned away as already paired. + let mut saved = config.clone(); + saved.paired.push(state::PairedDevice { + id: peer, + name: name.clone(), + paired_at: now_ms(), + addrs: std::collections::BTreeSet::new(), + claim: None, + }); + state::save(&saved)?; + *config = saved; + } + } + self.close_nearby().await; + let _ = self.announce().await; + let joined = self.join_notes(notes_id.clone()); + log::info("pair.confirmed") + .peer(peer, &name) + .field("by", "nearby") + .field("notes", ¬es_id) + .field("joined", joined) + .emit(); + self.broadcast().await; + Ok(()) + } + + async fn knock( + &self, + connection: &Connection, + peer: EndpointId, + name: &str, + ) -> Result { + let unreachable = |e: String| ("unreachable".to_string(), e); + let config = self.config(); + let mine = pairing::nonce(); + let knock = Request::Knock { + version: protocol::VERSION, + device_name: config.device_name.clone(), + notes_id: (!config.paired.is_empty()).then(|| config.notes_id.clone()), + commit: pairing::commit(&mine), + }; + let theirs = match session::request_small(connection, &knock).await { + Ok(Response::Knocked { nonce }) => nonce, + Ok(other) => return Err(refusal(other)), + Err(e) => return Err(unreachable(e)), + }; + let digits = pairing::check_digits(&config.endpoint_id(), &peer, &mine, &theirs); + + // Both prompts are up at once, so the digits can be read off one and typed into the other. + // Either device turning it down hangs up, which takes the other prompt down too. + let there = async { + let reply = session::request_small(connection, &Request::Reveal { nonce: mine }).await; + if !matches!(reply, Ok(Response::Allowed)) { + connection.close(0u32.into(), b"refused"); + } + reply + }; + let here = async { + let prompt = Prompt::Nearby { + digits: &digits, + asking: true, + }; + let answer = self.ask(connection, name, prompt).await; + if answer != Some(Answer::Allowed) { + connection.close(0u32.into(), b"declined"); + } + answer + }; + let (there, here) = tokio::join!(there, here); + match here { + Some(Answer::Declined) => { + return Err(( + "declined_here".to_string(), + "you didn't allow it, so the devices weren't paired".to_string(), + )) + } + Some(Answer::Mistyped) => { + return Err(( + "digits_mismatch".to_string(), + format!( + "that isn't the number on {name}, so the devices weren't paired. If you \ + typed it right, another device may be posing as it" + ), + )) + } + _ => {} + } + match there { + Ok(Response::Allowed) => {} + Ok(other) => return Err(refusal(other)), + Err(e) => return Err(unreachable(e)), + } + if here.is_none() { + return Err(( + pairing::Refused::NoAnswer.code().to_string(), + "nobody clicked Allow on this device in time".to_string(), + )); + } + + match session::request_small(connection, &Request::ConfirmKnock).await { + Ok(Response::Paired { notes_id }) => Ok(notes_id), + Ok(other) => Err(refusal(other)), + Err(e) => Err(unreachable(e)), + } + } + + /// Pairing by code (`rotate`) spends the code. + async fn admit( + self: &Arc, + peer: EndpointId, + name: String, + notes_id: Option<&str>, + now: f64, + rotate: bool, + ) -> Response { let joined = match self.joining(peer, notes_id) { Ok(joined) => joined, Err(refused) => return self.refuse(peer, refused).await, @@ -943,11 +1505,15 @@ impl Inner { let moved_notes = { let mut config = self.config.lock().expect("sync config poisoned"); let moved_notes = config.notes_id != joined; - config.paired.push(device.clone()); + // Paired once already, but the other device never heard back. + if !config.is_paired(&peer) { + config.paired.push(device.clone()); + } config.notes_id = joined.clone(); - // A code pairs one device. - config.pairing_secret = pairing::Secret::generate(); + if rotate { + config.pairing_secret = pairing::Secret::generate(); + } if let Err(e) = state::save(&config) { return Response::Error(e); } @@ -959,12 +1525,18 @@ impl Inner { .succeeded(&peer); log::info("pair.accepted") .peer(peer, &name) + .field("by", if rotate { "code" } else { "nearby" }) .field("notes", &joined) .field("moved_notes", moved_notes) .emit(); - let _ = self - .app - .emit("sync:paired", serde_json::json!({ "name": name })); + let _ = self.app.emit( + "sync:paired", + serde_json::json!({ "name": name, "byCode": rotate }), + ); + if !rotate { + self.close_nearby().await; + } + let _ = self.announce().await; self.broadcast().await; Response::Paired { notes_id: joined } @@ -981,13 +1553,34 @@ impl Inner { } /// `None` when nobody answered in time or the peer hung up. - async fn ask(&self, connection: &Connection, name: &str) -> Option { + async fn ask(&self, connection: &Connection, name: &str, prompt: Prompt<'_>) -> Option { let id: u32 = rand::random(); let (answer, answered) = tokio::sync::oneshot::channel(); *self.approval.lock().expect("sync approval poisoned") = Some((id, answer)); let _ = self.app.emit( "sync:pair-request", - serde_json::json!({ "id": id, "name": name }), + match prompt { + Prompt::Code => serde_json::json!({ "id": id, "name": name }), + // Not the digits: the person has to read them off the other device. + Prompt::Nearby { + asking: true, + digits, + } => serde_json::json!({ + "id": id, + "name": name, + "asking": true, + "digitCount": digits.chars().filter(char::is_ascii_digit).count(), + }), + Prompt::Nearby { + asking: false, + digits, + } => serde_json::json!({ + "id": id, + "name": name, + "checkDigits": digits, + "asking": false, + }), + }, ); let outcome = tokio::select! { @@ -996,6 +1589,7 @@ impl Inner { } _ = connection.closed() => None, }; + let outcome = outcome.map(|reply| prompt.answer(reply)); let _ = self .approval @@ -1037,6 +1631,53 @@ impl Inner { } } +#[derive(Clone, Copy)] +enum Prompt<'a> { + Code, + /// The picker (`asking`) types the check digits in rather than being shown them, so nobody can + /// allow it without comparing. + Nearby { + digits: &'a str, + asking: bool, + }, +} + +struct Reply { + allow: bool, + digits: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Answer { + Allowed, + Declined, + Mistyped, +} + +impl Prompt<'_> { + fn answer(self, reply: Reply) -> Answer { + if !reply.allow { + return Answer::Declined; + } + match self { + Prompt::Nearby { + digits, + asking: true, + } => { + let only_digits = + |s: &str| s.chars().filter(char::is_ascii_digit).collect::(); + let typed = reply.digits.as_deref().map(only_digits); + if typed.is_some_and(|typed| typed == only_digits(digits)) { + Answer::Allowed + } else { + Answer::Mistyped + } + } + _ => Answer::Allowed, + } + } +} + #[derive(Debug, Clone)] struct Handler(Weak); @@ -1056,6 +1697,21 @@ impl ProtocolHandler for Handler { } } +/// `(code, message)` +fn refusal(reply: Response) -> (String, String) { + match reply { + Response::Refused { code, message } => (code, message), + Response::Error(e) => ("peer_error".to_string(), e), + other => ( + "unexpected_reply".to_string(), + format!( + "the other device didn't understand this pairing request. The two are probably \ + running different versions of Set. Reply was {other:?}" + ), + ), + } +} + fn now_ms() -> f64 { crate::clock::now_ms() as f64 } @@ -1143,7 +1799,12 @@ pub async fn sync_regenerate_pairing_code(sync: tauri::State<'_, Sync>) -> Resul /// An id that timed out or was answered is ignored. #[tauri::command] -pub fn sync_answer_pair(sync: tauri::State<'_, Sync>, id: u32, allow: bool) { +pub fn sync_answer_pair( + sync: tauri::State<'_, Sync>, + id: u32, + allow: bool, + digits: Option, +) { let pending = sync .engine() .approval @@ -1151,7 +1812,7 @@ pub fn sync_answer_pair(sync: tauri::State<'_, Sync>, id: u32, allow: bool) { .expect("sync approval poisoned") .take_if(|(pending, _)| *pending == id); if let Some((_, answer)) = pending { - let _ = answer.send(allow); + let _ = answer.send(Reply { allow, digits }); } } @@ -1195,6 +1856,7 @@ pub async fn sync_pair(sync: tauri::State<'_, Sync>, code: String) -> Result, code: String) -> Result, code: String) -> Result, on: bool) -> Result { + let inner = sync.engine().clone(); + if on { + inner.search().await?; + } else { + inner.stop_search(None).await; + } + sync_status(sync).await +} + +#[tauri::command] +pub async fn sync_set_discoverable( + sync: tauri::State<'_, Sync>, + on: bool, +) -> Result { + let inner = sync.engine().clone(); + if on { + inner.discover().await?; + } else { + inner.hide(None).await; + } + sync_status(sync).await +} + +#[tauri::command] +pub async fn sync_pair_nearby(sync: tauri::State<'_, Sync>, id: String) -> Result { + let peer: EndpointId = id.parse().map_err(|e| format!("not a device id: {e}"))?; + sync.engine().clone().pair_nearby(peer).await?; + sync_status(sync).await +} + #[tauri::command] pub async fn sync_unpair(sync: tauri::State<'_, Sync>, id: String) -> Result { let inner = sync.engine().clone(); @@ -1241,6 +1936,7 @@ pub async fn sync_unpair(sync: tauri::State<'_, Sync>, id: String) -> Result Vec { pub fn sync_log_clear() -> Result<(), String> { log::clear() } + +#[cfg(test)] +mod prompt_tests { + use super::*; + + fn reply(allow: bool, digits: Option<&str>) -> Reply { + Reply { + allow, + digits: digits.map(str::to_string), + } + } + + #[test] + fn the_device_that_picked_must_type_the_digits() { + let asking = Prompt::Nearby { + digits: "123 456", + asking: true, + }; + assert_eq!(asking.answer(reply(true, Some("123456"))), Answer::Allowed); + assert_eq!( + asking.answer(reply(true, Some(" 123-456 "))), + Answer::Allowed + ); + assert_eq!(asking.answer(reply(true, Some("123457"))), Answer::Mistyped); + assert_eq!( + asking.answer(reply(true, None)), + Answer::Mistyped, + "just Allow" + ); + assert_eq!( + asking.answer(reply(false, Some("123456"))), + Answer::Declined + ); + } +} diff --git a/src-tauri/src/sync/nearby.rs b/src-tauri/src/sync/nearby.rs new file mode 100644 index 0000000..be7dbdd --- /dev/null +++ b/src-tauri/src/sync/nearby.rs @@ -0,0 +1,415 @@ +//! mDNS on the local network. Being nearby grants nothing: the allowlist and the claim are still +//! the only ways in. + +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use iroh::address_lookup::{AddressLookup, EndpointData, Error as LookupError, Item}; +use iroh::EndpointId; +use iroh_mdns_address_lookup::{DiscoveryEvent, MdnsAddressLookup}; +use tokio_stream::StreamExt; + +pub const SEARCH_WINDOW: Duration = Duration::from_secs(2 * 60); + +pub const DISCOVERABLE_WINDOW: Duration = Duration::from_secs(2 * 60); + +pub const SEARCH_EVERY: Duration = Duration::from_secs(4); + +/// Ours rather than iroh's shared `irohv1`, so Set only hears from Set. At most 15 bytes. +pub const SERVICE: &str = "set-sync"; + +/// Anyone on the network can announce as many ids as they like. +const MAX_HEARD: usize = 32; + +/// `None` without multicast; sync then goes through the relay alone. +pub fn lookup(me: EndpointId, advertise: bool) -> Option { + match MdnsAddressLookup::builder() + .service_name(SERVICE) + .advertise(advertise) + .build(me) + { + Ok(mdns) => Some(mdns), + Err(e) => { + super::log::warn("sync.mdns_unavailable") + .field("error", e.to_string()) + .emit(); + None + } + } +} + +/// mDNS can't start or stop announcing once built, so this swaps in a new one and the endpoint, +/// with any round on it, stays up. +#[derive(Clone, Debug)] +pub struct Lookup(Arc>); + +#[derive(Debug)] +struct Announcer { + me: EndpointId, + mdns: MdnsAddressLookup, + advertising: bool, + /// Republished by a swapped-in mDNS. + published: Option, +} + +impl Lookup { + pub fn new(me: EndpointId, advertise: bool) -> Option { + let mdns = lookup(me, advertise)?; + Some(Lookup(Arc::new(Mutex::new(Announcer { + me, + mdns, + advertising: advertise, + published: None, + })))) + } + + pub fn mdns(&self) -> MdnsAddressLookup { + self.0.lock().expect("mdns poisoned").mdns.clone() + } + + /// The new mDNS to follow, if it changed. + pub fn advertise(&self, advertise: bool) -> Option { + let mut announcer = self.0.lock().expect("mdns poisoned"); + if announcer.advertising == advertise { + return None; + } + let mdns = lookup(announcer.me, advertise)?; + if let Some(data) = &announcer.published { + mdns.publish(data); + } + announcer.mdns = mdns.clone(); + announcer.advertising = advertise; + Some(mdns) + } +} + +impl AddressLookup for Lookup { + fn publish(&self, data: &EndpointData) { + let mut announcer = self.0.lock().expect("mdns poisoned"); + announcer.published = Some(data.clone()); + announcer.mdns.publish(data); + } + + fn resolve( + &self, + endpoint_id: EndpointId, + ) -> Option> + Send>>> { + self.mdns().resolve(endpoint_id) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Introduction { + pub name: String, + pub version: u32, +} + +#[derive(Default)] +pub struct Nearby { + devices: HashMap>, + discoverable: Option, + search: Option, + windows: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Window { + /// Which press, so the timer of an earlier one can't end a later one. + pub id: u64, + /// Milliseconds since the epoch. + pub until: f64, +} + +impl Nearby { + /// Keeps the window counter, so an old timer can't end a new window. + pub fn reset(&mut self) { + *self = Nearby { + windows: self.windows, + ..Nearby::default() + }; + } + + pub fn forget_devices(&mut self) { + self.devices.clear(); + } + + pub fn contains(&self, id: &EndpointId) -> bool { + self.devices.contains_key(id) + } + + fn open(&mut self, now: f64, length: Duration) -> Window { + self.windows += 1; + Window { + id: self.windows, + until: now + length.as_millis() as f64, + } + } + + pub fn discover(&mut self, now: f64) -> Window { + let window = self.open(now, DISCOVERABLE_WINDOW); + self.discoverable = Some(window); + window + } + + /// `None`: whichever window is open. + pub fn hide(&mut self, id: Option) -> bool { + let open = is_current(self.discoverable, id); + if open { + self.discoverable = None; + } + open + } + + pub fn discoverable(&self, now: f64) -> bool { + self.discoverable.is_some_and(|w| now < w.until) + } + + pub fn open_to(&self, peer: &EndpointId, now: f64) -> bool { + self.discoverable(now) && self.contains(peer) + } + + pub fn search(&mut self, now: f64) -> Window { + let window = self.open(now, SEARCH_WINDOW); + self.search = Some(window); + window + } + + /// `None`: whichever. Also forgets the names heard. + pub fn stop_search(&mut self, id: Option) -> bool { + let running = is_current(self.search, id); + if running { + self.search = None; + for intro in self.devices.values_mut() { + *intro = None; + } + } + running + } + + pub fn searching(&self, now: f64) -> Option { + self.search.filter(|s| now < s.until) + } + + /// All of them every time: a device can become discoverable or stop being so. + pub fn devices(&self) -> Vec { + self.devices.keys().copied().collect() + } + + /// Whether Settings needs updating. + pub fn apply(&mut self, event: DiscoveryEvent, paired: impl Fn(&EndpointId) -> bool) -> bool { + match event { + DiscoveryEvent::Discovered { endpoint_info, .. } => { + let id = endpoint_info.endpoint_id; + let full = self.devices.len() >= MAX_HEARD; + match self.devices.entry(id) { + Entry::Occupied(_) => false, + Entry::Vacant(_) if full && !paired(&id) => false, + Entry::Vacant(entry) => { + entry.insert(None); + paired(&id) + } + } + } + DiscoveryEvent::Expired { endpoint_id } => match self.devices.remove(&endpoint_id) { + Some(intro) => intro.is_some() || paired(&endpoint_id), + None => false, + }, + _ => false, + } + } + + /// `None`: not discoverable (any more). + pub fn heard(&mut self, id: &EndpointId, introduction: Option) -> bool { + if self.search.is_none() { + return false; + } + match self.devices.get_mut(id) { + Some(slot) if *slot != introduction => { + *slot = introduction; + true + } + _ => false, + } + } + + pub fn introduced(&self) -> impl Iterator { + self.devices + .iter() + .filter_map(|(id, intro)| intro.as_ref().map(|intro| (id, intro))) + } + + pub fn name(&self, id: &EndpointId) -> Option<&str> { + self.devices + .get(id)? + .as_ref() + .map(|intro| intro.name.as_str()) + } +} + +fn is_current(window: Option, id: Option) -> bool { + match (window, id) { + (Some(window), Some(id)) => window.id == id, + (Some(_), None) => true, + (None, _) => false, + } +} + +pub async fn watch(mdns: MdnsAddressLookup, mut changed: impl FnMut(DiscoveryEvent)) { + let mut events = mdns.subscribe().await; + while let Some(event) = events.next().await { + changed(event); + } +} + +#[cfg(test)] +mod tests { + use iroh::SecretKey; + + use super::*; + + const NOW: f64 = 1_720_000_000_000.0; + + fn found(nearby: &mut Nearby) -> EndpointId { + let id = SecretKey::generate().public(); + nearby.apply(discovered(id), |_| false); + id + } + + fn discovered(id: EndpointId) -> DiscoveryEvent { + DiscoveryEvent::Discovered { + endpoint_info: iroh::address_lookup::EndpointInfo::new(id), + last_updated: None, + } + } + + fn laptop() -> Introduction { + Introduction { + name: "Laptop".to_string(), + version: 5, + } + } + + #[test] + fn only_a_discoverable_device_is_open_to_its_neighbours() { + let mut nearby = Nearby::default(); + let id = found(&mut nearby); + assert!(!nearby.open_to(&id, NOW), "not discoverable yet"); + let first = nearby.discover(NOW); + assert!(nearby.open_to(&id, NOW)); + assert!( + !nearby.open_to(&SecretKey::generate().public(), NOW), + "not on this network" + ); + let window = DISCOVERABLE_WINDOW.as_millis() as f64; + assert!(!nearby.open_to(&id, NOW + window), "it runs out by itself"); + + let second = nearby.discover(NOW + window); + assert!(!nearby.hide(Some(first.id)), "an old timer leaves it on"); + assert!(nearby.open_to(&id, NOW + window)); + assert!(nearby.hide(Some(second.id))); + assert!(!nearby.open_to(&id, NOW + window)); + } + + #[test] + fn windows_from_before_a_restart_end_nothing_after_it() { + let mut nearby = Nearby::default(); + let before = nearby.search(NOW); + nearby.reset(); + let after = nearby.search(NOW); + assert_ne!(before.id, after.id); + assert!(!nearby.stop_search(Some(before.id))); + } + + #[test] + fn strangers_can_only_fill_so_much_of_the_list() { + let mut nearby = Nearby::default(); + for _ in 0..MAX_HEARD * 4 { + found(&mut nearby); + } + assert_eq!(nearby.devices.len(), MAX_HEARD); + let paired = SecretKey::generate().public(); + assert!( + nearby.apply(discovered(paired), |id| *id == paired), + "a paired device always gets in, and shows" + ); + assert!(nearby.contains(&paired)); + } + + #[test] + fn a_search_lists_names_until_it_stops() { + let mut nearby = Nearby::default(); + let id = found(&mut nearby); + assert!(!nearby.heard(&id, Some(laptop())), "not searching"); + + let first = nearby.search(NOW); + assert!(nearby.searching(NOW + 1000.0).is_some()); + assert!(nearby.heard(&id, Some(laptop()))); + assert!(!nearby.heard(&id, Some(laptop())), "nothing new"); + assert_eq!(nearby.name(&id), Some("Laptop")); + assert_eq!(nearby.devices(), vec![id], "still asked"); + + assert!(nearby.heard(&id, None)); + assert_eq!(nearby.name(&id), None); + nearby.heard(&id, Some(laptop())); + + let window = SEARCH_WINDOW.as_millis() as f64; + assert!( + nearby.searching(NOW + window).is_none(), + "the search ran out" + ); + + let second = nearby.search(NOW + window); + assert!(!nearby.stop_search(Some(first.id))); + assert!(nearby.searching(NOW + window + 1000.0).is_some()); + + assert!(nearby.stop_search(Some(second.id))); + assert_eq!( + nearby.name(&id), + None, + "names are forgotten when the search stops" + ); + assert!(nearby.contains(&id), "but the device is still nearby"); + } + + #[test] + fn follows_announcements_and_expiries() { + let id = SecretKey::generate().public(); + let mut nearby = Nearby::default(); + assert!(!nearby.apply(DiscoveryEvent::Expired { endpoint_id: id }, |_| false)); + assert!( + !nearby.apply(discovered(id), |_| false), + "a stranger arriving shows nothing until it says who it is" + ); + assert!(!nearby.apply(discovered(id), |_| false)); + assert!(nearby.contains(&id)); + assert_eq!(nearby.introduced().count(), 0); + + nearby.search(NOW); + assert!(nearby.heard(&id, Some(laptop()))); + assert_eq!(nearby.name(&id), Some("Laptop")); + nearby.apply(discovered(id), |_| false); + assert_eq!(nearby.name(&id), Some("Laptop")); + + assert!( + nearby.apply(DiscoveryEvent::Expired { endpoint_id: id }, |_| false), + "a named device leaving drops off the list" + ); + assert!(!nearby.contains(&id)); + assert!( + !nearby.heard(&id, Some(laptop())), + "gone devices aren't named" + ); + + let paired = SecretKey::generate().public(); + assert!(nearby.apply(discovered(paired), |_| true)); + assert!(nearby.apply( + DiscoveryEvent::Expired { + endpoint_id: paired + }, + |_| true + )); + } +} diff --git a/src-tauri/src/sync/pairing.rs b/src-tauri/src/sync/pairing.rs index 549b891..615babf 100644 --- a/src-tauri/src/sync/pairing.rs +++ b/src-tauri/src/sync/pairing.rs @@ -126,6 +126,17 @@ pub enum Refused { /// The code was right and nobody answered the prompt in time. NoAnswer, + + NotDiscoverable, + + DeclinedNearby, + + /// A step of pairing nearby that came too late, out of turn, or on another connection. + KnockExpired, + + KnockMismatch, + + Busy, } impl Refused { @@ -137,6 +148,11 @@ impl Refused { Refused::Version { .. } => "version_mismatch", Refused::Declined => "declined", Refused::NoAnswer => "no_answer", + Refused::NotDiscoverable => "not_discoverable", + Refused::DeclinedNearby => "declined_nearby", + Refused::KnockExpired => "knock_expired", + Refused::KnockMismatch => "knock_mismatch", + Refused::Busy => "pairing_busy", } } } @@ -169,10 +185,87 @@ impl std::fmt::Display for Refused { f, "nobody clicked Allow on the other device. Try again and answer the prompt there" ), + Refused::NotDiscoverable => write!( + f, + "the other device isn't discoverable. Turn on Discoverable in its Sync settings and try again" + ), + Refused::DeclinedNearby => write!( + f, + "the other device didn't allow it. You can ask again in a few minutes" + ), + Refused::KnockExpired => write!(f, "the pairing request ran out of time. Try again"), + Refused::KnockMismatch => write!( + f, + "the other device's answer didn't check out, so it wasn't paired. Try again" + ), + Refused::Busy => write!( + f, + "the other device is showing another pairing prompt. Try again in a moment" + ), } } } +pub type Nonce = [u8; 32]; + +pub fn nonce() -> Nonce { + rand::random() +} + +/// Sent before the other device's nonce is seen, so the nonce can't be chosen to suit it. +pub fn commit(nonce: &Nonce) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"set/sync/knock-commit"); + hasher.update(nonce); + *hasher.finalize().as_bytes() +} + +/// Neither device can choose its nonce after seeing the other's, so a device in the middle gets +/// matching digits only by a one in a million chance. +pub fn check_digits( + knocker: &EndpointId, + knocked: &EndpointId, + knocker_nonce: &Nonce, + knocked_nonce: &Nonce, +) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"set/sync/check-digits"); + hasher.update(knocker.as_bytes()); + hasher.update(knocked.as_bytes()); + hasher.update(knocker_nonce); + hasher.update(knocked_nonce); + let bytes = hasher.finalize(); + let n = u64::from_le_bytes(bytes.as_bytes()[..8].try_into().expect("eight bytes")) % 1_000_000; + format!("{:03} {:03}", n / 1000, n % 1000) +} + +const MAX_NAME: usize = 64; + +/// No control or invisible formatting characters, which could make it read as another name. +pub fn device_name(given: &str, id: &EndpointId) -> String { + let shown: String = given + .chars() + .filter(|c| !c.is_control() && !invisible(*c)) + .collect(); + let shown: String = shown.trim().chars().take(MAX_NAME).collect(); + match shown.trim_end() { + "" => format!("Device {}", &id.to_string()[..8]), + named => named.to_string(), + } +} + +fn invisible(c: char) -> bool { + matches!(c, + '\u{00AD}' + | '\u{061C}' + | '\u{180E}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{2069}' + | '\u{FEFF}' + ) +} + pub fn may_claim(their_version: u32) -> Result<(), Refused> { if their_version != super::protocol::VERSION { return Err(Refused::Version { @@ -235,11 +328,163 @@ impl Throttle { failures.latest = now; } + /// So a stranger on the network can't keep a prompt on screen. + pub fn block(&mut self, peer: EndpointId, now: f64) { + self.failed(peer, now); + if let Some(failures) = self.0.get_mut(&peer) { + failures.count = failures.count.max(MAX_ATTEMPTS); + } + } + pub fn succeeded(&mut self, peer: &EndpointId) { self.0.remove(peer); } } +const REVEAL_TIMEOUT: Duration = Duration::from_secs(30); + +const MAX_KNOCKS: usize = 8; + +/// Pairing nearby, from the device asked. Each device saves the other only once Allow is clicked +/// on it, so it takes three requests on the knock's connection: +/// +/// 1. `Knock` carries the knocker's commitment; this device answers with its own nonce. +/// 2. `Reveal` carries the knocker's nonce. This device shows the check digits and asks, and +/// answers `Allowed` once someone clicks Allow here. +/// 3. `ConfirmKnock` comes once the digits are typed in on the knocker and Allow clicked there +/// too. Only then does this device save it. +#[derive(Default)] +pub struct Knocks(HashMap); + +struct Knock { + connection: usize, + at: f64, + name: String, + notes_id: Option, + stage: Stage, +} + +enum Stage { + Committed { commit: [u8; 32], nonce: Nonce }, + Asking, + Allowed, +} + +pub struct Revealed { + pub nonce: Nonce, + pub name: String, +} + +impl Knocks { + pub fn knock( + &mut self, + peer: EndpointId, + connection: usize, + name: String, + notes_id: Option, + commit: [u8; 32], + now: f64, + ) -> Nonce { + if self.0.len() >= MAX_KNOCKS && !self.0.contains_key(&peer) { + let oldest = self + .0 + .iter() + .min_by(|a, b| a.1.at.total_cmp(&b.1.at)) + .map(|(id, _)| *id); + if let Some(oldest) = oldest { + self.0.remove(&oldest); + } + } + let nonce = nonce(); + self.0.insert( + peer, + Knock { + connection, + at: now, + name, + notes_id, + stage: Stage::Committed { commit, nonce }, + }, + ); + nonce + } + + pub fn reveal( + &mut self, + peer: &EndpointId, + connection: usize, + theirs: &Nonce, + now: f64, + ) -> Result { + let knock = self.current(peer, connection)?; + let Stage::Committed { + commit: committed, + nonce, + } = knock.stage + else { + return Err(Refused::KnockExpired); + }; + if now - knock.at > REVEAL_TIMEOUT.as_millis() as f64 { + self.0.remove(peer); + return Err(Refused::KnockExpired); + } + if commit(theirs) != committed { + self.0.remove(peer); + return Err(Refused::KnockMismatch); + } + knock.stage = Stage::Asking; + Ok(Revealed { + nonce, + name: knock.name.clone(), + }) + } + + /// Refused if the knock was dropped while asking, so the knocker isn't told to confirm it. + pub fn allowed( + &mut self, + peer: &EndpointId, + connection: usize, + now: f64, + ) -> Result<(), Refused> { + let knock = self.current(peer, connection)?; + if !matches!(knock.stage, Stage::Asking) { + return Err(Refused::KnockExpired); + } + knock.stage = Stage::Allowed; + knock.at = now; + Ok(()) + } + + pub fn forget(&mut self, peer: &EndpointId) { + self.0.remove(peer); + } + + pub fn confirm( + &mut self, + peer: &EndpointId, + connection: usize, + now: f64, + ) -> Result<(String, Option), Refused> { + let knock = self.current(peer, connection)?; + if !matches!(knock.stage, Stage::Allowed) { + return Err(Refused::KnockExpired); + } + let late = now - knock.at > APPROVAL_TIMEOUT.as_millis() as f64; + let knock = self.0.remove(peer).expect("just found"); + if late { + return Err(Refused::KnockExpired); + } + Ok((knock.name, knock.notes_id)) + } + + fn current(&mut self, peer: &EndpointId, connection: usize) -> Result<&mut Knock, Refused> { + match self.0.get_mut(peer) { + Some(knock) if knock.connection == connection => Ok(knock), + _ => Err(Refused::KnockExpired), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -471,4 +716,153 @@ mod tests { ); } } + + #[test] + fn check_digits_depend_on_both_nonces() { + let (a, b) = (peer(), peer()); + let (na, nb) = (nonce(), nonce()); + let digits = check_digits(&a, &b, &na, &nb); + assert_eq!(digits, check_digits(&a, &b, &na, &nb)); + assert_eq!(digits.len(), 7); + assert_ne!(digits, check_digits(&a, &b, &na, &nonce())); + assert_ne!(digits, check_digits(&a, &b, &nonce(), &nb)); + } + + #[test] + fn a_name_is_cleaned_before_anyone_reads_it() { + let id = peer(); + assert_eq!(device_name(" Laptop\n", &id), "Laptop"); + assert_eq!( + device_name("Lap\u{202E}pot\u{200B}", &id), + "Lappot", + "no reordering or invisible characters" + ); + assert_eq!(device_name(&"x".repeat(500), &id).chars().count(), MAX_NAME); + assert!(device_name("\u{200B} \t", &id).starts_with("Device ")); + } + + const CONN: usize = 7; + + fn knocked(knocks: &mut Knocks, p: EndpointId) -> Nonce { + knocked_at(knocks, p, NOW) + } + + fn knocked_at(knocks: &mut Knocks, p: EndpointId, at: f64) -> Nonce { + let mine = nonce(); + knocks.knock(p, CONN, "Laptop".into(), None, commit(&mine), at); + mine + } + + #[test] + fn a_knock_pairs_only_after_reveal_allow_and_confirm() { + let mut knocks = Knocks::default(); + let p = peer(); + let mine = knocked(&mut knocks, p); + assert_eq!( + knocks.confirm(&p, CONN, NOW).err(), + Some(Refused::KnockExpired), + "not before anyone allowed it" + ); + assert!(knocks.reveal(&p, CONN, &mine, NOW + 1000.0).is_ok()); + assert_eq!( + knocks.reveal(&p, CONN, &mine, NOW + 1000.0).err(), + Some(Refused::KnockExpired), + "revealed once" + ); + assert_eq!( + knocks.confirm(&p, CONN, NOW).err(), + Some(Refused::KnockExpired), + "still asking" + ); + knocks.allowed(&p, CONN, NOW + 2000.0).expect("allowed"); + assert_eq!( + knocks.confirm(&p, CONN + 1, NOW + 3000.0).err(), + Some(Refused::KnockExpired), + "another connection" + ); + assert_eq!( + knocks.confirm(&p, CONN, NOW + 3000.0), + Ok(("Laptop".to_string(), None)) + ); + assert!(knocks.confirm(&p, CONN, NOW + 3000.0).is_err(), "once"); + } + + #[test] + fn a_reveal_must_match_its_commitment_and_come_in_time() { + let mut knocks = Knocks::default(); + let p = peer(); + knocked(&mut knocks, p); + assert_eq!( + knocks.reveal(&p, CONN, &nonce(), NOW).err(), + Some(Refused::KnockMismatch) + ); + + let mine = knocked(&mut knocks, p); + let late = NOW + REVEAL_TIMEOUT.as_millis() as f64 + 1.0; + assert_eq!( + knocks.reveal(&p, CONN, &mine, late).err(), + Some(Refused::KnockExpired) + ); + } + + #[test] + fn a_confirm_after_the_approval_window_is_refused() { + let mut knocks = Knocks::default(); + let p = peer(); + let mine = knocked(&mut knocks, p); + knocks.reveal(&p, CONN, &mine, NOW).expect("reveal"); + knocks.allowed(&p, CONN, NOW).expect("allowed"); + let late = NOW + APPROVAL_TIMEOUT.as_millis() as f64 + 1.0; + assert_eq!( + knocks.confirm(&p, CONN, late).err(), + Some(Refused::KnockExpired) + ); + } + + #[test] + fn allowing_a_knock_that_was_dropped_is_refused() { + let mut knocks = Knocks::default(); + let p = peer(); + let mine = knocked(&mut knocks, p); + knocks.reveal(&p, CONN, &mine, NOW).expect("reveal"); + // Knocked again on another connection while the first was being asked about. + knocks.knock(p, CONN + 1, "Laptop".into(), None, commit(&nonce()), NOW); + assert_eq!( + knocks.allowed(&p, CONN, NOW).err(), + Some(Refused::KnockExpired) + ); + knocks.forget(&p); + assert_eq!( + knocks.allowed(&p, CONN, NOW).err(), + Some(Refused::KnockExpired), + "gone" + ); + } + + #[test] + fn knocks_stay_bounded() { + let mut knocks = Knocks::default(); + let first = peer(); + knocked_at(&mut knocks, first, NOW - 1.0); + for _ in 0..MAX_KNOCKS { + knocked(&mut knocks, peer()); + } + assert_eq!(knocks.0.len(), MAX_KNOCKS); + assert!(!knocks.0.contains_key(&first), "the oldest made way"); + } + + #[test] + fn a_device_turned_down_waits_out_the_cooldown() { + let mut throttle = Throttle::default(); + let p = peer(); + throttle.block(p, NOW); + assert_eq!( + throttle.allows(&p, NOW + 1000.0), + Err(Refused::TooManyAttempts) + ); + assert_eq!( + throttle.allows(&p, NOW + COOLDOWN.as_millis() as f64), + Ok(()) + ); + } } diff --git a/src-tauri/src/sync/protocol.rs b/src-tauri/src/sync/protocol.rs index 346fa12..bcc828b 100644 --- a/src-tauri/src/sync/protocol.rs +++ b/src-tauri/src/sync/protocol.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use super::apply::Applied; use super::manifest::{Hash, Scan}; -use super::pairing::Secret; +use super::pairing::{Nonce, Secret}; use super::plan::{Batch, Settled, Stamp, Version}; use super::state::{Generation, GenerationId, Mark}; @@ -83,6 +83,24 @@ pub enum Request { Lineage { pages: Vec<(String, Option)>, }, + + /// Answered only while discoverable. + Introduce, + + /// Pairing without a code; the rest is `pairing::Knocks`. + Knock { + version: u32, + device_name: String, + notes_id: Option, + /// `pairing::commit` of the nonce `Reveal` carries. + commit: [u8; 32], + }, + + Reveal { + nonce: Nonce, + }, + + ConfirmKnock, } #[derive(Debug, Serialize, Deserialize)] @@ -129,6 +147,17 @@ pub enum Response { actor: String, stamps: Vec>, }, + + Introduced { + version: u32, + device_name: String, + }, + + Knocked { + nonce: Nonce, + }, + + Allowed, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -317,6 +346,18 @@ mod tests { 9 ); assert_eq!(index(&Request::Lineage { pages: Vec::new() }), 10); + assert_eq!(index(&Request::Introduce), 11); + assert_eq!( + index(&Request::Knock { + version: VERSION, + device_name: String::new(), + notes_id: None, + commit: [0; 32], + }), + 12 + ); + assert_eq!(index(&Request::Reveal { nonce: [0; 32] }), 13); + assert_eq!(index(&Request::ConfirmKnock), 14); } #[test] @@ -372,6 +413,15 @@ mod tests { }), 11 ); + assert_eq!( + index(&Response::Introduced { + version: VERSION, + device_name: String::new(), + }), + 12 + ); + assert_eq!(index(&Response::Knocked { nonce: [0; 32] }), 13); + assert_eq!(index(&Response::Allowed), 14); } #[test] diff --git a/src-tauri/src/sync/round.rs b/src-tauri/src/sync/round.rs index cf92b53..472a0d9 100644 --- a/src-tauri/src/sync/round.rs +++ b/src-tauri/src/sync/round.rs @@ -1019,8 +1019,12 @@ impl Session { Response::Ok } - Request::Claim { .. } => { - return Err("a pairing claim was routed to the wrong place".to_string()) + Request::Claim { .. } + | Request::Introduce + | Request::Knock { .. } + | Request::Reveal { .. } + | Request::ConfirmKnock => { + return Err("a pairing request was routed to the wrong place".to_string()) } }) } diff --git a/src-tauri/src/sync/session.rs b/src-tauri/src/sync/session.rs index 63969e8..5db12ae 100644 --- a/src-tauri/src/sync/session.rs +++ b/src-tauri/src/sync/session.rs @@ -99,6 +99,19 @@ fn silence_for(request: &Request) -> Option { } pub async fn request(connection: &Connection, request: &Request) -> Result { + request_limited(connection, request, protocol::MAX_MESSAGE).await +} + +/// For a reply from a device that isn't paired, which is no bigger than a claim. +pub async fn request_small(connection: &Connection, request: &Request) -> Result { + request_limited(connection, request, protocol::MAX_CLAIM).await +} + +async fn request_limited( + connection: &Connection, + request: &Request, + limit: usize, +) -> Result { let (mut send, mut recv) = connection .open_bi() .await @@ -113,7 +126,7 @@ pub async fn request(connection: &Connection, request: &Request) -> Result(); let confirmButton = $state(); let cancelButton = $state(); + let entryInput = $state(); let dontAskAgain = $state(false); + let entered = $state(""); + + /** Only the digits count, so "123 456" and "123456" are the same. */ + const enteredDigits = $derived(entered.replace(/\D/g, "")); + const entryDone = $derived( + !request?.entry || enteredDigits.length === request.entry.digits, + ); + + function confirm(): void { + if (!entryDone) return; + confirms.confirm(dontAskAgain, request?.entry ? enteredDigits : undefined); + } let top = $state(0); let left = $state(0); @@ -36,10 +49,15 @@ return; } dontAskAgain = false; + entered = ""; reposition(); // After paint: the popover is `visibility: hidden` until measured, and a hidden button cannot // take focus. - const wanted = request.permanent ? cancelButton : confirmButton; + const wanted = request.entry + ? entryInput + : request.permanent + ? cancelButton + : confirmButton; void tick().then(() => wanted?.focus()); const onScroll = () => reposition(); @@ -93,6 +111,26 @@ {/if}

{request.message}

+ {#if request.entry} + { + if (e.key === "Enter") { + e.preventDefault(); + confirm(); + } + }} + data-testid="confirm-entry" + /> + {/if} {#if request.remember}