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
20 changes: 19 additions & 1 deletion desktop/src-tauri/src/key_backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,19 @@ pub fn cleanup_stale_backup(
Ok(())
}

/// Whether `word` can sit in a phrase joined by `separator` without hiding a
/// word boundary.
///
/// The EFF short wordlist 2.0 contains one hyphenated entry (`yo-yo`), so a
/// hyphen-joined phrase holding it reads as one more word than it has — both
/// to a human retyping the phrase and to anything counting the parts. Dropping
/// one of 1296 words costs ~0.001 bits per word.
///
/// An empty separator delimits nothing, so nothing is excluded.
fn word_is_delimitable(word: &str, separator: &str) -> bool {
separator.is_empty() || !word.contains(separator)
}

/// Generate a passphrase of `word_count` EFF short-wordlist words joined by
/// `separator`, using OS entropy.
///
Expand All @@ -196,6 +209,8 @@ pub fn cleanup_stale_backup(
/// and re-drawn — the result always passes the same length gate applied to
/// user-chosen passphrases. Uses rejection sampling for a uniform
/// distribution over the 1296 words.
///
/// Words holding the separator are skipped — see [`word_is_delimitable`].
pub fn generate_passphrase(word_count: usize, separator: &str) -> Result<String, String> {
let word_count = word_count.clamp(MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS);
let words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect();
Expand All @@ -218,7 +233,10 @@ pub fn generate_passphrase(word_count: usize, separator: &str) -> Result<String,
// Rejection sampling: accept only values below the largest
// multiple of 1296 that fits in u16 (65536 - 65536 % 1296 = 64800).
if value < 64800 {
chosen.push(words[(value as usize) % 1296]);
let word = words[(value as usize) % 1296];
if word_is_delimitable(word, separator) {
chosen.push(word);
}
}
}
let phrase = chosen.join(separator);
Expand Down
29 changes: 29 additions & 0 deletions desktop/src-tauri/src/key_backup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,35 @@ fn generated_passphrase_respects_word_count_and_separator() {
}
}

#[test]
fn hyphenated_wordlist_entry_is_excluded_from_hyphen_joined_phrases() {
// `yo-yo` is the wordlist's only entry holding a separator we use. Before
// it was skipped it turned a 10-word phrase into 11 parts, failing roughly
// one CI run in a hundred.
assert!(!word_is_delimitable("yo-yo", "-"));
assert!(word_is_delimitable("yo-yo", " "));
assert!(word_is_delimitable("yo-yo", ""));
assert!(word_is_delimitable("acid", "-"));
assert_eq!(
WORDLIST
.lines()
.filter(|word| !word_is_delimitable(word, "-"))
.collect::<Vec<_>>(),
vec!["yo-yo"],
"a new hyphenated entry would need the same treatment"
);
}

#[test]
fn generated_phrases_never_hide_a_word_boundary() {
// 512 × 10 words: pre-fix the odds of never drawing `yo-yo` here are ~2e-2,
// so this is a guard rail, not the primary assertion above.
for _ in 0..512 {
let phrase = generate_passphrase(MAX_PASSPHRASE_WORDS, "-").unwrap();
assert_eq!(phrase.split('-').count(), MAX_PASSPHRASE_WORDS);
}
}

#[test]
fn generated_passphrase_clamps_word_count() {
// Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter.
Expand Down
6 changes: 6 additions & 0 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,12 @@ mod tests {
use crate::relay_admission::MAX_HINT_SECONDS;
use std::io::{Read as _, Write as _};

// `relay_error_message` arms the process-wide admission gate on a 429,
// so this test mutates the same static the `relay_admission` tests own.
// Without the guard its 300s window lands on whichever of them is
// parked at the time, and that one waits out the cap.
let _gate = crate::relay_admission::test_support::lock_gate().await;

// Use a std::net listener on a std::thread — the same pattern as the
// relay_admission loopback tests. This avoids two races that cause CI
// failures with tokio::net + into_std():
Expand Down
114 changes: 70 additions & 44 deletions desktop/src-tauri/src/relay_admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,23 +96,49 @@ pub fn reset_gate_for_workspace_change() {
/// Reset the gate. Test-only: production never clears an armed window early
/// except via `reset_gate_for_workspace_change`.
#[cfg(test)]
pub fn reset_rate_limit_gate() {
fn reset_rate_limit_gate() {
*GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner()) = None;
}

/// Exclusive, self-clearing access to the gate for the duration of one test.
#[cfg(test)]
pub(crate) mod test_support {
/// The gate is a process-wide static and Rust runs tests in parallel
/// threads, so every test that arms it must serialize here — including
/// tests in other modules that reach it indirectly through
/// `relay::relay_error_message`, which arms the gate on any 429.
static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

/// Holds the serial lock and clears the gate on both acquire and drop, so
/// a test can neither inherit another's armed window nor bequeath its own.
///
/// A leaked window is not a small error: a waiter parked on its own 2s
/// expiry re-reads the static and adopts whatever it now holds, so one
/// unserialized 300s arming makes an unrelated test wait 300s.
pub(crate) struct GateGuard {
_serial: tokio::sync::MutexGuard<'static, ()>,
}

impl Drop for GateGuard {
fn drop(&mut self) {
super::reset_rate_limit_gate();
}
}

pub(crate) async fn lock_gate() -> GateGuard {
let serial = TEST_SERIAL.lock().await;
super::reset_rate_limit_gate();
GateGuard { _serial: serial }
}
}

#[cfg(test)]
mod tests {
use super::*;

// The gate is a process-wide static shared by every test in this binary,
// so all gate tests serialize on one async lock to keep armed expiries
// from bleeding between parallel test threads.
pub(crate) static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

#[tokio::test(start_paused = true)]
async fn wait_returns_immediately_when_gate_is_inactive() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
let start = Instant::now();
wait_for_rate_limit().await;
assert_eq!(
Expand All @@ -124,19 +150,16 @@ mod tests {

#[tokio::test(start_paused = true)]
async fn hintless_429_arms_the_ten_second_default() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
activate_rate_limit(None);
let start = Instant::now();
wait_for_rate_limit().await;
assert_eq!(Instant::now() - start, Duration::from_secs(10));
reset_rate_limit_gate();
}

#[tokio::test(start_paused = true)]
async fn shorter_hint_never_shrinks_an_active_window() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
activate_rate_limit(Some(8));
activate_rate_limit(Some(1));
let start = Instant::now();
Expand All @@ -146,13 +169,11 @@ mod tests {
Duration::from_secs(8),
"the 1s hint must not shorten the active 8s window"
);
reset_rate_limit_gate();
}

#[tokio::test(start_paused = true)]
async fn concurrent_429_extends_the_window_for_parked_waiters() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
activate_rate_limit(Some(2));
let start = Instant::now();
let waiter = tokio::spawn(async {
Expand All @@ -167,15 +188,37 @@ mod tests {
Duration::from_secs(5),
"waiter must respect the extension armed mid-sleep (1s + 4s)"
);
reset_rate_limit_gate();
}

/// No armed window may outlive the guard that armed it.
///
/// This is the invariant a 300s window leaking out of
/// `relay::tests::oversized_hint_is_capped_in_relay_error_message_string`
/// broke: the waiter above re-reads the static after its own expiry and
/// adopts whatever it finds, so it waited 300s for a 5s window.
#[tokio::test(start_paused = true)]
async fn an_armed_window_does_not_outlive_its_guard() {
let gate = test_support::lock_gate().await;
activate_rate_limit(Some(MAX_HINT_SECONDS));
drop(gate);

// Robust against a concurrent gate test winning the lock in between:
// whoever holds it also hands it back clear.
let _gate = test_support::lock_gate().await;
let start = Instant::now();
wait_for_rate_limit().await;
assert_eq!(
Instant::now(),
start,
"the next holder of the guard must inherit a clear gate"
);
}

// ── hint capping and overflow safety ─────────────────────────────────────

#[tokio::test(start_paused = true)]
async fn hint_zero_uses_default() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
activate_rate_limit(Some(0));
let start = Instant::now();
wait_for_rate_limit().await;
Expand All @@ -184,13 +227,11 @@ mod tests {
Duration::from_secs(DEFAULT_RATE_LIMIT_SECONDS),
"hint=0 must use the default"
);
reset_rate_limit_gate();
}

#[tokio::test(start_paused = true)]
async fn hint_at_max_is_honoured() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
activate_rate_limit(Some(MAX_HINT_SECONDS));
let start = Instant::now();
wait_for_rate_limit().await;
Expand All @@ -199,13 +240,11 @@ mod tests {
Duration::from_secs(MAX_HINT_SECONDS),
"hint at the cap must be honoured in full"
);
reset_rate_limit_gate();
}

#[tokio::test(start_paused = true)]
async fn oversize_hint_is_clamped_to_max() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
// An oversize hint (including u64::MAX) must clamp rather than panic.
activate_rate_limit(Some(u64::MAX));
let start = Instant::now();
Expand All @@ -215,15 +254,13 @@ mod tests {
Duration::from_secs(MAX_HINT_SECONDS),
"u64::MAX hint must clamp to MAX_HINT_SECONDS"
);
reset_rate_limit_gate();
}

// ── community / workspace boundary ───────────────────────────────────────

#[tokio::test(start_paused = true)]
async fn workspace_change_clears_armed_gate() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
activate_rate_limit(Some(60));
// Switch workspace — gate for community A must not stall community B.
reset_gate_for_workspace_change();
Expand All @@ -238,8 +275,7 @@ mod tests {

#[tokio::test(start_paused = true)]
async fn community_a_gate_does_not_block_community_b() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;
// Community A gets a 429 with a 30s window.
activate_rate_limit(Some(30));
// Community switch.
Expand All @@ -264,8 +300,7 @@ mod tests {
async fn gate_armed_by_one_path_withholds_another_path() {
use std::io::{Read, Write};

let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;

// The loopback server answers every request with 200 [].
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down Expand Up @@ -312,7 +347,6 @@ mod tests {
);

drop(server);
reset_rate_limit_gate();
}

/// A waiter parked on community A's gate must NOT wake early when the gate
Expand All @@ -326,8 +360,7 @@ mod tests {
/// is the same bound as if the workspace had not changed.
#[tokio::test(start_paused = true)]
async fn parked_waiter_does_not_wake_early_after_workspace_reset() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;

// Arm a 5s gate for community A.
activate_rate_limit(Some(5));
Expand Down Expand Up @@ -356,8 +389,6 @@ mod tests {
"waiter woke at {}ms — must not wake before A's 5s expiry even after reset",
elapsed.as_millis()
);

reset_rate_limit_gate();
}

/// Wait-then-sign ensures NIP-98 auth is fresh after an admission wait.
Expand All @@ -375,8 +406,7 @@ mod tests {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::Deserialize;

let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;

// Arm the gate for 1s (real time — NIP-98 uses SystemTime, not Tokio clock).
activate_rate_limit(Some(1));
Expand Down Expand Up @@ -420,8 +450,6 @@ mod tests {
shell.created_at,
wake_unix
);

reset_rate_limit_gate();
}

/// Acceptance: a 429 from one relay-backed command withholds the next
Expand All @@ -435,8 +463,7 @@ mod tests {
async fn http_429_withholds_next_relay_command_until_expiry_then_resumes() {
use std::io::{Read, Write};

let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
let _gate = test_support::lock_gate().await;

let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
Expand Down Expand Up @@ -499,6 +526,5 @@ mod tests {
);

server.join().unwrap();
reset_rate_limit_gate();
}
}
Loading