Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
235 changes: 202 additions & 33 deletions crates/entangle/src/repeater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -72,6 +79,32 @@ fn read_approval_key(max: u8) -> Result<KeyOutcome> {
}
}

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<YesNo> {
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<i32> {
writeln!(output, "\r\nCancelled; nothing was approved or denied.")?;
Ok(1)
Expand Down Expand Up @@ -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`.",
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<String> {
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<i32> {
let store = Store::new(root);
let now = chrono::Utc::now();
Expand Down Expand Up @@ -576,38 +652,37 @@ fn approve_request(root: PathBuf, output: &mut dyn Write) -> Result<i32> {
}
}
};
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::<Vec<_>>();
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)
Expand All @@ -626,7 +701,6 @@ fn approve_request(root: PathBuf, output: &mut dyn Write) -> Result<i32> {
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 {
Expand Down Expand Up @@ -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];
Expand Down
6 changes: 4 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading