diff --git a/Cargo.lock b/Cargo.lock index 75dd4f9..528e15f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,7 +453,7 @@ dependencies = [ [[package]] name = "entangle" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "async-trait", @@ -488,7 +488,7 @@ dependencies = [ [[package]] name = "entangle-core" -version = "0.7.0" +version = "0.7.1" dependencies = [ "hex", "rand", @@ -500,7 +500,7 @@ dependencies = [ [[package]] name = "entangle-link" -version = "0.7.0" +version = "0.7.1" dependencies = [ "async-trait", "entangle-core", @@ -514,7 +514,7 @@ dependencies = [ [[package]] name = "entangle-mcp" -version = "0.7.0" +version = "0.7.1" dependencies = [ "async-trait", "serde_json", @@ -524,7 +524,7 @@ dependencies = [ [[package]] name = "entangle-repeater" -version = "0.7.0" +version = "0.7.1" dependencies = [ "chrono", "data-encoding", @@ -546,7 +546,7 @@ dependencies = [ [[package]] name = "entangle-resonance" -version = "0.7.0" +version = "0.7.1" dependencies = [ "entangle-core", "gethostname", @@ -560,7 +560,7 @@ dependencies = [ [[package]] name = "entangle-transport" -version = "0.7.0" +version = "0.7.1" dependencies = [ "snow", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index 6b39dee..6a18400 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.7.0" +version = "0.7.1" edition = "2021" rust-version = "1.89" authors = ["Dailen Gunter"] diff --git a/crates/entangle/src/repeater.rs b/crates/entangle/src/repeater.rs index daeca6e..f45b5f9 100644 --- a/crates/entangle/src/repeater.rs +++ b/crates/entangle/src/repeater.rs @@ -6,9 +6,7 @@ use crossterm::{ event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, terminal::{disable_raw_mode, enable_raw_mode}, }; -use entangle_core::sas; use entangle_repeater::{client, invite, server, store::Store}; -use rand::seq::SliceRandom; use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::Digest; @@ -30,6 +28,15 @@ pub(crate) enum KeyOutcome { Ignore, } +#[derive(Debug, PartialEq, Eq)] +enum YesNo { + Yes, + No, + Cancel, + Invalid, + Ignore, +} + fn classify_key(code: KeyCode, modifiers: KeyModifiers) -> KeyOutcome { match code { KeyCode::Esc => KeyOutcome::Cancel, @@ -72,6 +79,32 @@ fn read_approval_key(max: u8) -> Result { } } +fn classify_yes_no(code: KeyCode, modifiers: KeyModifiers) -> YesNo { + match code { + KeyCode::Char('y' | 'Y') => YesNo::Yes, + KeyCode::Char('n' | 'N') => YesNo::No, + KeyCode::Esc | KeyCode::Char('q' | 'Q') => YesNo::Cancel, + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => YesNo::Cancel, + KeyCode::Modifier(_) => YesNo::Ignore, + _ => YesNo::Invalid, + } +} + +fn read_yes_no_key() -> Result { + let _raw_mode = RawModeGuard::enter()?; + loop { + let outcome = match read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + classify_yes_no(key.code, key.modifiers) + } + _ => YesNo::Ignore, + }; + if outcome != YesNo::Ignore { + return Ok(outcome); + } + } +} + fn cancel_approval(output: &mut dyn Write) -> Result { writeln!(output, "\r\nCancelled; nothing was approved or denied.")?; Ok(1) @@ -185,6 +218,30 @@ fn short_fingerprint(public: &[u8; 32]) -> String { hex::encode(sha2::Sha256::digest(public))[..8].to_owned() } +fn long_fingerprint(public: &[u8; 32]) -> String { + let fingerprint = hex::encode(sha2::Sha256::digest(public)); + format!( + "{} {} {} {}", + &fingerprint[..4], + &fingerprint[4..8], + &fingerprint[8..12], + &fingerprint[12..16] + ) +} + +fn request_waiting_message( + host: &str, + repeater_public: &[u8; 32], + device_public: &[u8; 32], +) -> String { + format!( + "Asked the Quantum Repeater at {host} to enroll this device (repeater fingerprint {}).\nThis device's fingerprint: {}\nRepeater fingerprint: {}\nOn the host, run `entangle repeater approve` and check that it shows the same two fingerprints.\nWaiting for the host to decide (up to 10 minutes; Ctrl+C cancels)...", + short_fingerprint(repeater_public), + long_fingerprint(device_public), + long_fingerprint(repeater_public) + ) +} + fn join_summary(host: &str, repeater_public: &[u8; 32], device_public: &[u8; 32]) -> String { format!( "Joined the Quantum Repeater at {host} (repeater fingerprint {}).\nThis device's fingerprint: {} (listed by `entangle repeater devices` on the host).\nA running Entangle node connects within 30 seconds; check with `entangle repeater status`.", @@ -456,11 +513,12 @@ async fn request_join( biased; received = &mut sas_rx, if !printed && !sas_closed => { match received { - Ok((public, sas)) => { - writeln!(output, "Asked the Quantum Repeater at {host} to enroll this device (repeater fingerprint {}).", short_fingerprint(&public))?; - writeln!(output, "On the host, run `entangle repeater approve` and pick:")?; - writeln!(output, "{}", sas::render(sas))?; - writeln!(output, "Waiting for the host to decide (up to 10 minutes; Ctrl+C cancels)...")?; + Ok((public, _sas)) => { + writeln!( + output, + "{}", + request_waiting_message(&host, &public, &device_public) + )?; output.flush()?; printed = true; } @@ -518,6 +576,24 @@ fn request_age(requested_at: &str) -> String { } } +fn approval_prompt( + request: &entangle_repeater::store::EnrollmentRequest, + age: &str, + repeater_public: &[u8; 32], +) -> Result { + let public: [u8; 32] = hex::decode(&request.key)? + .try_into() + .map_err(|_| anyhow!("invalid request key"))?; + let label = request.label.as_deref().unwrap_or("(unnamed)"); + let device = request.label.as_deref().unwrap_or("this device"); + Ok(format!( + "{label} from {}, {age} ago\nDevice fingerprint: {}\nRepeater fingerprint: {}\nDoes `entangle repeater join` on {device} show these two fingerprints? Approve? [y/n] ", + request.source, + long_fingerprint(&public), + long_fingerprint(repeater_public) + )) +} + fn approve_request(root: PathBuf, output: &mut dyn Write) -> Result { let store = Store::new(root); let now = chrono::Utc::now(); @@ -576,38 +652,37 @@ fn approve_request(root: PathBuf, output: &mut dyn Write) -> Result { } } }; - writeln!( - output, - "On {}, which set does `entangle repeater join` show?", - request.label.as_deref().unwrap_or("(unnamed)") - )?; - let real = request.sas; - let mut sets = sas::decoys(real, &mut rand::thread_rng()) - .into_iter() - .collect::>(); - sets.push(real); - sets.shuffle(&mut rand::thread_rng()); - for (index, set) in sets.iter().enumerate() { - writeln!(output, "{}) {}", index + 1, sas::render(*set))?; - } - writeln!(output, "5) none of these")?; + let repeater_public = entangle_transport::public_key(&store.key()?); let mut invalid = 0; - let choice = loop { - write!(output, "Press 1-5: ")?; + let approved = loop { + let prompt = approval_prompt( + &request, + &request_age(&request.requested_at), + &repeater_public, + )?; + output.write_all(prompt.as_bytes())?; output.flush()?; - match read_approval_key(5)? { - KeyOutcome::Pick(value) => { - writeln!(output, "{value}")?; - break usize::from(value); + match read_yes_no_key()? { + YesNo::Yes => { + writeln!(output, "y")?; + break true; + } + YesNo::No => { + writeln!(output, "n")?; + break false; } - KeyOutcome::Cancel => return cancel_approval(output), - _ => { + YesNo::Cancel => return cancel_approval(output), + YesNo::Invalid => { invalid += 1; - writeln!(output, "\r\nPress a number from 1 to 5.")?; + writeln!( + output, + "\r\nPress y to approve, n to deny, or Esc to cancel." + )?; if invalid == 3 { return cancel_approval(output); } } + YesNo::Ignore => unreachable!(), } }; let still_pending = store.requests()?.contains(&request) @@ -626,7 +701,6 @@ fn approve_request(root: PathBuf, output: &mut dyn Write) -> Result { let key: [u8; 32] = hex::decode(&request.key)? .try_into() .map_err(|_| anyhow!("invalid request key"))?; - let approved = choice != 5 && sets[choice - 1] == real; store.decide_device(key, request.label.clone(), approved)?; let public: [u8; 32] = key; if approved { @@ -745,13 +819,108 @@ fn reject_symlink_components(path: &Path) -> Result<()> { #[cfg(test)] mod tests { - use super::{join_summary, leave_summary, short_fingerprint}; + use super::{ + approval_prompt, classify_yes_no, join_summary, leave_summary, long_fingerprint, + request_waiting_message, short_fingerprint, YesNo, + }; + use crossterm::event::{KeyCode, KeyModifiers, ModifierKeyCode}; + use entangle_repeater::store::EnrollmentRequest; #[test] fn repeater_short_fingerprint_matches_known_sha256() { assert_eq!(short_fingerprint(&[0; 32]), "66687aad"); } + #[test] + fn repeater_long_fingerprint_matches_known_sha256() { + assert_eq!(long_fingerprint(&[0; 32]), "6668 7aad f862 bd77"); + } + + #[test] + fn request_waiting_message_shows_device_fingerprint() { + assert_eq!( + request_waiting_message("relay.example", &[1; 32], &[0; 32]), + "Asked the Quantum Repeater at relay.example to enroll this device (repeater fingerprint 72cd6e84).\nThis device's fingerprint: 6668 7aad f862 bd77\nRepeater fingerprint: 72cd 6e84 22c4 07fb\nOn the host, run `entangle repeater approve` and check that it shows the same two fingerprints.\nWaiting for the host to decide (up to 10 minutes; Ctrl+C cancels)..." + ); + } + + fn request(label: Option<&str>) -> EnrollmentRequest { + EnrollmentRequest { + key: hex::encode([0; 32]), + label: label.map(str::to_owned), + source: "192.0.2.7".to_owned(), + requested_at: "2025-01-01T00:00:00Z".to_owned(), + sas: [1, 2, 3], + } + } + + #[test] + fn approval_prompt_shows_label_source_and_fingerprint() { + assert_eq!( + approval_prompt(&request(Some("alice")), "3s", &[1; 32]).unwrap(), + "alice from 192.0.2.7, 3s ago\nDevice fingerprint: 6668 7aad f862 bd77\nRepeater fingerprint: 72cd 6e84 22c4 07fb\nDoes `entangle repeater join` on alice show these two fingerprints? Approve? [y/n] " + ); + } + + #[test] + fn approval_prompt_uses_device_for_unlabelled_request() { + assert_eq!( + approval_prompt(&request(None), "4s", &[1; 32]).unwrap(), + "(unnamed) from 192.0.2.7, 4s ago\nDevice fingerprint: 6668 7aad f862 bd77\nRepeater fingerprint: 72cd 6e84 22c4 07fb\nDoes `entangle repeater join` on this device show these two fingerprints? Approve? [y/n] " + ); + } + + #[test] + fn yes_no_classification_handles_approval_denial_and_cancellation() { + assert_eq!( + classify_yes_no(KeyCode::Char('y'), KeyModifiers::NONE), + YesNo::Yes + ); + assert_eq!( + classify_yes_no(KeyCode::Char('Y'), KeyModifiers::NONE), + YesNo::Yes + ); + assert_eq!( + classify_yes_no(KeyCode::Char('n'), KeyModifiers::NONE), + YesNo::No + ); + assert_eq!( + classify_yes_no(KeyCode::Char('N'), KeyModifiers::NONE), + YesNo::No + ); + assert_eq!( + classify_yes_no(KeyCode::Esc, KeyModifiers::NONE), + YesNo::Cancel + ); + assert_eq!( + classify_yes_no(KeyCode::Char('q'), KeyModifiers::NONE), + YesNo::Cancel + ); + assert_eq!( + classify_yes_no(KeyCode::Char('Q'), KeyModifiers::NONE), + YesNo::Cancel + ); + assert_eq!( + classify_yes_no(KeyCode::Char('c'), KeyModifiers::CONTROL), + YesNo::Cancel + ); + assert_eq!( + classify_yes_no(KeyCode::Char('x'), KeyModifiers::NONE), + YesNo::Invalid + ); + assert_eq!( + classify_yes_no(KeyCode::Enter, KeyModifiers::NONE), + YesNo::Invalid + ); + assert_eq!( + classify_yes_no( + KeyCode::Modifier(ModifierKeyCode::LeftShift), + KeyModifiers::NONE + ), + YesNo::Ignore + ); + } + #[test] fn repeater_join_summary_reports_both_fingerprints() { let repeater_public = [0; 32]; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6874adf..a789753 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -152,8 +152,10 @@ authorization boundary. The group-derived PSK and membership-bound static key are the end-to-end security boundary. Devices can request repeater enrollment at `{path}/request` without knowing the -repeater key: Noise `XX` authenticates the exchange, then an encrypted -commit/reveal SAS lets a host operator approve the matching emoji set. Pending +repeater key: Noise `XX` authenticates the exchange. The host operator +approves after comparing the device and repeater fingerprints shown on both +the device and the host. The protocol still runs the commit/reveal SAS +exchange for wire compatibility, but the CLI no longer displays it. Pending requests and device decisions use single-writer atomic files; the repeater alone writes `requests.json`, while the host CLI updates the device allowlist. diff --git a/docs/SETUP.md b/docs/SETUP.md index e494845..ec09bf1 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -275,10 +275,15 @@ On the device, run: entangle repeater join ws://HOST:7338/entangle ``` -On the host, run `entangle repeater approve` and choose the matching emoji set. -The emoji check protects against interception. The operator can disable -request enrollment with `entangle repeater serve --no-requests`; invitation -enrollment remains available. +The device's `join` command prints its fingerprint. On the host, run +`entangle repeater approve` and compare the device fingerprint shown there with +the one printed on the device. Also compare the repeater fingerprint shown on +both screens. Press `y` to approve; `n` denies, and Esc cancels. URL-request +enrollment requires Entangle 0.7.1 or later on both the device and host; with +an older host or device, use an invite code (`entangle repeater invite` / +`entangle repeater join `), which works across versions. The operator +can disable request enrollment with `entangle repeater serve --no-requests`; +invitation enrollment remains available. The host can inspect enrolled device fingerprints with `entangle repeater devices` and revoke a device by fingerprint prefix with diff --git a/docs/playbooks/06-self-host-a-repeater.md b/docs/playbooks/06-self-host-a-repeater.md index 371750d..618cba1 100644 --- a/docs/playbooks/06-self-host-a-repeater.md +++ b/docs/playbooks/06-self-host-a-repeater.md @@ -101,8 +101,12 @@ Use this when devices on different networks need a common relay for their encryp 7. **On a device and the repeater host:** If you cannot share an invite code, use URL-request enrollment instead. The device command is human-only; the - host operator then compares the displayed symbol set and runs the - human-only approval command. + host operator compares the device and repeater fingerprints shown on both + screens, then presses `y` to approve. Press `n` to deny or Esc to cancel. + URL-request enrollment requires Entangle 0.7.1 or later on both the device + and host; with an older host or device, use an invite code + (`entangle repeater invite` / `entangle repeater join `), which works + across versions. ```sh entangle repeater join ws://:7338/entangle