From 4c59412d43f497f29b4c3d4b9695134f634cf17f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 21:40:53 -0700 Subject: [PATCH] fix: Live Share codes are Crockford base32, like 7KQ-4MZ-9XR, in place of words Two random words made codes like "678-scary-virus". A code is now nine symbols of Crockford base32 (0-9 and A-Z without I, L, O and U), shown in threes: two numbering the relay's room (it numbers 1 to 999, which two symbols hold), six of secret, 30 bits, and a check symbol, the symbols weighted 1 to 8 modulo 31, which refuses a mistyped symbol or two swapped before it spends one of the relay's tries. Reading ignores case, hyphens and spaces and takes I and L for 1, O for 0. With five tries before a code burns, a guess at one code succeeds with odds 5 in 2^30, about 1 in 215 million; every burn draws a new secret, and the relay's lockout leaves one address about ten tries an hour. The relay is unchanged: rooms are still code-. A word code and a new code each read as malformed on the other version, before any network. Assisted-by: claude-opus-5.5 --- arc/sync.md | 3 +- crates/notebook/README.md | 8 +- crates/notebook/src/live.rs | 50 +- crates/notebook/src/live/code.rs | 148 +++ crates/notebook/src/live/relay.rs | 6 +- crates/notebook/src/live/share.rs | 56 +- crates/notebook/src/live/tests.rs | 21 +- crates/notebook/src/live/words.txt | 1296 --------------------------- crates/notebook/tests/live_share.rs | 31 +- crates/snowbound/src/share.rs | 4 +- 10 files changed, 233 insertions(+), 1390 deletions(-) create mode 100644 crates/notebook/src/live/code.rs delete mode 100644 crates/notebook/src/live/words.txt diff --git a/arc/sync.md b/arc/sync.md index f6d65116a65292cb1e32c84c7f56cd554ea278ad..f232dd17ce6577a91851880acc8648a1044e04f0 100644 --- a/arc/sync.md +++ b/arc/sync.md @@ -320,7 +320,8 @@ in the share's room; a guest runs the replica, queue and merge it runs on an SMB them (`Notebook::open_hosted`, `Section::resume_hosted`, `Background::hosted`), so offline queueing, rebases and conflict pages behave as on a share, and the host's files are only ever written by its own storage, OneNote's locks included. A guest meets the host first in the -room of a short code (`412-violet-otter`, SPAKE2 on its words and any password) through +room of a short code (`7KQ-4MZ-9XR`: Crockford base32, its room's number, 30 bits of +secret and a check symbol; SPAKE2 on its secret and any password) through Snowbound's relay or by mDNS, and is welcomed with the share's room and its random secret; stopping or restarting a share retires the secret. A guest's commit is checked on the host's image before it is committed, and a guest can name nothing outside the notebook. Large reads diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 8561bf8315d5b770f808d1649ad25a642044ef20..fadb051de72b984fd933127b7a90ce6ff7d8753f 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -538,10 +538,12 @@ physical power-loss durability. `live::Live::start(hello, room, reach, relay, events)` listens on a TCP port and, with a `Reach`, advertises `_snowbound._tcp` by mDNS on every network or on loopback alone, connecting to the peers in the same `Room` that it finds: a notebook's or a share's random -secret (`Room::Notebook`), or a code typed on both (`Room::join("4-violet-otter", password)`). +secret (`Room::Notebook`), or a code typed on both (`Room::join("7KQ-4MZ-9XR", password)`). With a `relay` (`wss://live.example.net`, `crates/relay`) it also joins the room there and meets its peers through it; the end sharing a code (`Room::share`) has the relay number its -words, `code()` then has the whole code, and it burns a code after too many wrong tries. +secret, `code()` then has the whole code, and it burns a code after too many wrong tries. +Codes are Crockford base32 (`live::code`): two symbols numbering the room, six of secret +(30 bits) and a check symbol that refuses a typo before it spends one of the relay's tries. `connect(address)` meets a peer discovery did not find. Peers meet through SPAKE2 on the room's secret, then every frame is AES-256-GCM under the keys it agreed: its number, which is also its nonce, then a message kind and a CBOR map (`live::wire`). A frame lost, repeated, @@ -558,7 +560,7 @@ a notebook's presence room, kept in `.snowbound/live.json` and made where it has `live::share` is Live Share. `Host::start(storage, hello, sharing, name, reach, relay, events)` serves `Notebook::into_storage()` to the peers in the share's room and welcomes whoever knows `Sharing::code` (and its password) from the code's room; `code()` replaces a burned code with -new words, `guests()` lists who is connected, `touched(paths)` passes the host's own changes +a new secret, `guests()` lists who is connected, `touched(paths)` passes the host's own changes on, and `stop()` lets every guest go. `join(hello, code, password, reach, relay)` returns the `Welcome` (the share and its secret), or a `Refusal` saying why not: a wrong code, no one sharing it, an expired code, too many wrong tries, or no relay. `Guest::start` joins the share; diff --git a/crates/notebook/src/live.rs b/crates/notebook/src/live.rs index 0e6f01143e0eb4e1c55a7ca88c1051311c4f76f0..42858132ae28436450f7ef5611b7e81fb9252709 100644 --- a/crates/notebook/src/live.rs +++ b/crates/notebook/src/live.rs @@ -7,6 +7,7 @@ //! and one writing. A connection whose frames arrive out of order is dropped and met again //! from scratch. +pub mod code; mod relay; pub mod share; pub mod wire; @@ -44,10 +45,10 @@ const TRIES: u32 = 5; pub enum Room { /// A notebook's room, or a share's: a random secret only its members hold. Notebook([u8; 16]), - /// A code typed on both ends, `412-violet-otter`, with a password where one is set: its - /// number names it on the network, and its words and password only the two people know. + /// A code typed on both ends (`code`), with a password where one is set: its room's + /// number names it on the network, and its secret and password only the two people know. /// Its `owner`, the end sharing it, takes the number from a relay (the one the code has, - /// coming back; any free one for words alone) or picks one where it has no relay, and + /// coming back; any free one for a secret alone) or picks one where it has no relay, and /// judges every try, burning the code after too many wrong ones. Code { code: String, @@ -60,16 +61,16 @@ impl Room { /// The room a code typed on this end leads to. pub fn join(code: &str, password: &str) -> Self { Self::Code { - code: code.trim().to_lowercase(), + code: code.to_owned(), password: password.to_owned(), owner: false, } } - /// The room of a code this end shares: its words, or a whole code to keep its number. + /// The room of a code this end shares: its secret, or a whole code to keep its number. pub fn share(code: &str, password: &str) -> Self { Self::Code { - code: code.trim().to_lowercase(), + code: code.to_owned(), password: password.to_owned(), owner: true, } @@ -90,7 +91,7 @@ impl Room { match self { Room::Notebook(id) => id.to_vec(), Room::Code { code, password, .. } => { - let mut secret = code_parts(code).1.to_lowercase().into_bytes(); + let mut secret = code_parts(code).1.into_bytes(); if !password.is_empty() { secret.push(b'\n'); secret.extend_from_slice(password.as_bytes()); @@ -105,12 +106,11 @@ impl Room { } } -/// A code's number, if it has one, and its words. -fn code_parts(code: &str) -> (Option, &str) { - let code = code.trim(); - match code.split_once('-') { - Some((number, words)) if number.parse::().is_ok() => (number.parse().ok(), words), - _ => (None, code), +/// A code's room number, if it has one, and its secret: a whole code, or a secret alone. +fn code_parts(code: &str) -> (Option, String) { + match code::parse(code) { + Some((number, secret)) => (Some(number), secret), + None => (None, code.trim().to_uppercase()), } } @@ -309,18 +309,18 @@ impl Live { address.set_ip(IpAddr::V4(Ipv4Addr::LOCALHOST)); } let code = match room { - Room::Code { code, .. } if room.tag().is_some() => Some(code.clone()), - // Off any relay, the end sharing words alone numbers them itself. - Room::Code { code, .. } if relay.is_none() => { - let mut number = [0; 2]; - getrandom::fill(&mut number) - .map_err(|_| io::Error::other("System random source failed"))?; - Some(format!( - "{}-{code}", - 1000 + u16::from_le_bytes(number) % 9000 - )) - } - _ => None, + Room::Code { code, .. } => match code_parts(code) { + (Some(number), secret) => code::format(number, &secret), + // Off any relay, the end sharing a secret alone numbers it itself. + (None, secret) if relay.is_none() => { + let mut number = [0; 4]; + getrandom::fill(&mut number) + .map_err(|_| io::Error::other("System random source failed"))?; + code::format(u32::from_le_bytes(number) % code::NAMEPLATES, &secret) + } + (None, _) => None, + }, + Room::Notebook(_) => None, }; let shared = Arc::new(Shared { me, diff --git a/crates/notebook/src/live/code.rs b/crates/notebook/src/live/code.rs new file mode 100644 index 0000000000000000000000000000000000000000..e9a95d0e9664d728ff09430409eb0b5dde7d3b9a --- /dev/null +++ b/crates/notebook/src/live/code.rs @@ -0,0 +1,148 @@ +//! Live Share's codes in Crockford's base32 (0-9 and A-Z without I, L, O and U), shown +//! `7KQ-4MZ-9XR`: two symbols naming the code's room, its number on a relay; six of secret +//! (30 bits), which SPAKE2 meets through; and a check symbol, so a mistyped code is refused +//! here before it spends one of the relay's few tries. Reading ignores case, hyphens and +//! spaces, and takes I and L for 1 and O for 0, as Crockford's decoding does. +//! +//! The check is the symbols' values weighted 1 to 8, summed modulo 31, the prime under 32, so +//! it stays one of the code's own symbols: it catches any symbol mistyped and any two +//! neighbours swapped, but for 0 and Z, whose values differ by 31. Crockford's own check +//! symbol, modulo 37, adds `*~$=U`, which read badly aloud. + +use std::io; + +const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +/// The symbols naming a code's room, and how many rooms they name. +const NAMEPLATE: usize = 2; +pub const NAMEPLATES: u32 = 1 << (5 * NAMEPLATE); +/// The symbols of a code's secret. +pub const SECRET: usize = 6; + +/// A new secret, `SECRET` random symbols. +pub fn secret() -> io::Result { + let mut bytes = [0; 4]; + getrandom::fill(&mut bytes).map_err(|_| io::Error::other("System random source failed"))?; + let bits = u32::from_le_bytes(bytes); + Ok((0..SECRET) + .map(|at| symbol((bits >> (5 * at)) as u8)) + .collect()) +} + +fn symbol(value: u8) -> char { + char::from(ALPHABET[usize::from(value & 31)]) +} + +/// A symbol's value as typed, reading I and L as 1 and O as 0. +fn value(typed: char) -> Option { + let typed = match typed.to_ascii_uppercase() { + 'I' | 'L' => '1', + 'O' => '0', + typed => typed, + }; + ALPHABET + .iter() + .position(|symbol| char::from(*symbol) == typed) + .map(|at| at as u8) +} + +fn check(values: &[u8]) -> u8 { + let sum: u32 = (values.iter().enumerate()) + .map(|(at, value)| (at as u32 + 1) * u32::from(*value)) + .sum(); + (sum % 31) as u8 +} + +/// The code for room `nameplate` and `secret`, as shown: `7KQ-4MZ-9XR`. A secret that isn't +/// `SECRET` symbols has no code. +pub fn format(nameplate: u32, secret: &str) -> Option { + let secret: Vec = secret.chars().map(value).collect::>()?; + if nameplate >= NAMEPLATES || secret.len() != SECRET { + return None; + } + let mut values = vec![(nameplate >> 5) as u8, (nameplate & 31) as u8]; + values.extend(secret); + values.push(check(&values)); + let symbols: Vec = values.into_iter().map(symbol).collect(); + Some( + symbols + .chunks(3) + .map(|group| group.iter().collect::()) + .collect::>() + .join("-"), + ) +} + +/// A code as typed: its room's number and its secret, where it is a code whose check holds. +pub fn parse(typed: &str) -> Option<(u32, String)> { + let values: Vec = typed + .chars() + .filter(|c| *c != '-' && !c.is_whitespace()) + .map(value) + .collect::>()?; + let (check_value, values) = values.split_last()?; + if values.len() != NAMEPLATE + SECRET || check(values) != *check_value { + return None; + } + let nameplate = (u32::from(values[0]) << 5) | u32::from(values[1]); + let secret = values[NAMEPLATE..].iter().copied().map(symbol).collect(); + Some((nameplate, secret)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_read_back_however_they_are_typed() { + let code = format(412, "4MZ9XR").unwrap(); + assert_eq!(code.len(), 11); + assert_eq!(parse(&code), Some((412, "4MZ9XR".into()))); + let typed = code + .to_lowercase() + .replace('-', " ") + .replace('1', "l") + .replace('0', "o"); + assert_eq!(parse(&typed), Some((412, "4MZ9XR".into()))); + assert_eq!(parse(&format!(" {code} ")), Some((412, "4MZ9XR".into()))); + assert!(format(NAMEPLATES, "4MZ9XR").is_none() && format(1, "4MZ9X").is_none()); + assert!(parse("412-violet-otter").is_none() && parse("").is_none()); + for _ in 0..100 { + let secret = secret().unwrap(); + assert_eq!(secret.len(), SECRET); + assert_eq!(parse(&format(7, &secret).unwrap()), Some((7, secret))); + } + } + + /// The check refuses any one symbol mistyped, and any two neighbours swapped, but for 0 + /// and Z. + #[test] + fn the_check_catches_a_symbol_mistyped_or_two_swapped() { + let code: Vec = format(999, "Q4MZ9X") + .unwrap() + .replace('-', "") + .chars() + .collect(); + for at in 0..code.len() { + for symbol in ALPHABET.iter().map(|byte| char::from(*byte)) { + let mut typed = code.clone(); + if typed[at] != symbol && !matches!((typed[at], symbol), ('0', 'Z') | ('Z', '0')) { + typed[at] = symbol; + assert!( + parse(&typed.iter().collect::()).is_none(), + "{typed:?}" + ); + } + } + } + for at in 0..code.len() - 1 { + let mut typed = code.clone(); + typed.swap(at, at + 1); + if typed != code { + assert!( + parse(&typed.iter().collect::()).is_none(), + "{typed:?}" + ); + } + } + } +} diff --git a/crates/notebook/src/live/relay.rs b/crates/notebook/src/live/relay.rs index b892514292c45361d9dd60005350782bebce751e..bd6c7c80d1e5a6f3a0f02e7673f0898aa1ade6db 100644 --- a/crates/notebook/src/live/relay.rs +++ b/crates/notebook/src/live/relay.rs @@ -372,10 +372,12 @@ fn session( Ok(Notice::Nameplate(number)) => { *nameplate = Some(number); tag = Some(format!("code-{number}")); - let super::Room::Code { code: words, .. } = &shared.room else { + let super::Room::Code { code, .. } = &shared.room else { + continue; + }; + let Some(code) = super::code::format(number, &code_parts(code).1) else { continue; }; - let code = format!("{number}-{}", code_parts(words).1); let mut state = shared.state.lock().unwrap(); if state.code.as_ref() != Some(&code) { state.code = Some(code); diff --git a/crates/notebook/src/live/share.rs b/crates/notebook/src/live/share.rs index d9b06c802bcac13a9068ec57f761ac5d10553b30..00594c4e3920e36f255794877a6ab5219c2f5bb0 100644 --- a/crates/notebook/src/live/share.rs +++ b/crates/notebook/src/live/share.rs @@ -6,9 +6,6 @@ //! the host welcomes it with the share's room and secret; a new share has a new secret, so //! stopping retires every guest. Large bodies travel a chunk at a time, each answered before //! the next, so a relay never holds much for a slow peer. -//! -//! The code's words come from the EFF's short word list -//! (, CC BY 3.0 US), `yo-yo` replaced by `yarn`. use super::{ Event, Hello, Line, Live, Peer, Presence, Reach, Relayed, Room, @@ -55,42 +52,11 @@ const WORKERS: usize = 2; const STARTS: f64 = 100.0; const BURST: f64 = 200.0; -const WORDS: &str = include_str!("words.txt"); - -/// Two random words for a code, `violet-otter`. -pub fn words() -> io::Result { - let list: Vec<&str> = WORDS.lines().collect(); - let mut bytes = [0; 8]; - getrandom::fill(&mut bytes).map_err(|_| io::Error::other("System random source failed"))?; - let [first, second] = [&bytes[..4], &bytes[4..]] - .map(|bytes| u32::from_le_bytes(bytes.try_into().expect("4 bytes")) as usize); - let first = first % list.len(); - let mut second = second % (list.len() - 1); - if second >= first { - second += 1; - } - Ok(format!("{}-{}", list[first], list[second])) -} - -/// A code as typed, `412 Violet otter`, in the form it is met by, `412-violet-otter`; none -/// where it is not a number and two words. +/// A code as typed, `7kq 4mz 9xr`, as it is shown, `7KQ-4MZ-9XR`; none where it is not a +/// code (`super::code`). pub fn code(typed: &str) -> Option { - let parts: Vec = typed - .split(|c: char| c.is_whitespace() || c == '-') - .filter(|part| !part.is_empty()) - .map(str::to_lowercase) - .collect(); - match &parts[..] { - [number, first, second] - if number.parse::().is_ok() - && [first, second] - .iter() - .all(|word| word.chars().all(|c| c.is_ascii_lowercase())) => - { - Some(parts.join("-")) - } - _ => None, - } + let (number, secret) = super::code::parse(typed)?; + super::code::format(number, &secret) } /// What a host keeps of a share to take it up again after a relaunch. @@ -99,13 +65,13 @@ pub struct Sharing { pub share: [u8; 16], /// The share room's secret, which every guest welcomed holds. pub secret: [u8; 16], - /// The code: its words, with its number in front once one was given. + /// The code, or its secret alone until it has a number (`super::code`). pub code: String, pub password: String, } impl Sharing { - /// A new share: its own id and secret, and new words. + /// A new share: its own id, room secret and code. pub fn new(password: &str) -> io::Result { let mut random = [0; 32]; getrandom::fill(&mut random) @@ -113,7 +79,7 @@ impl Sharing { Ok(Self { share: random[..16].try_into().expect("16 bytes"), secret: random[16..].try_into().expect("16 bytes"), - code: words()?, + code: super::code::secret()?, password: password.to_owned(), }) } @@ -127,9 +93,9 @@ pub fn location(share: &[u8; 16]) -> String { /// Why joining failed. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Refusal { - /// Not a number and two words. + /// Not a code, or one mistyped, which spends none of the relay's tries. Malformed, - /// The code's words or password are wrong. + /// The code's secret or password is wrong. Wrong, /// No one shares with the code's number now. NoOne, @@ -281,13 +247,13 @@ impl Host { } /// The code guests type, once it has its number, and none once stopped. A code with too - /// many wrong tries is replaced by one with new words. + /// many wrong tries is replaced by one with a new secret. pub fn code(&self) -> Option { let mut pairing = self.pairing.lock().unwrap(); let pairing = pairing.as_mut()?; if pairing.burned() { let mut sharing = self.sharing.lock().unwrap(); - sharing.code = words().ok()?; + sharing.code = super::code::secret().ok()?; let relay = self.relay.as_deref(); *pairing = pair( &self.me, diff --git a/crates/notebook/src/live/tests.rs b/crates/notebook/src/live/tests.rs index 942f4ef1b9b70d9372a50c8ae073887f0fa3121e..77c6ded383b650541a905743e8e0f18f51be10d5 100644 --- a/crates/notebook/src/live/tests.rs +++ b/crates/notebook/src/live/tests.rs @@ -1,6 +1,11 @@ use super::*; use std::time::Instant; +/// The code for room `number` and `secret`. +fn code(number: u32, secret: &str) -> String { + super::code::format(number, secret).unwrap() +} + fn hello(name: &str) -> Hello { Hello::new(name.into(), Some(vec![1, 2, 3])).unwrap() } @@ -43,7 +48,7 @@ fn caret(offset: u32) -> Presence { /// caret, and see the other leave. #[test] fn peers_meet_and_follow_presence() { - let room = Room::join("7-violet-otter", ""); + let room = Room::join(&code(7, "ABCDEF"), ""); let ada = Live::start(hello("Ada"), &room, None, None, |_| {}).unwrap(); let grace = Live::start(hello("Grace"), &room, None, None, |_| {}).unwrap(); ada.set_presence(caret(1)); @@ -70,7 +75,7 @@ fn peers_meet_and_follow_presence() { fn another_code_never_meets() { let ada = Live::start( hello("Ada"), - &Room::join("7-violet-otter", ""), + &Room::join(&code(7, "ABCDEF"), ""), None, None, |_| {}, @@ -78,7 +83,7 @@ fn another_code_never_meets() { .unwrap(); let mallory = Live::start( hello("Mallory"), - &Room::join("7-violet-ocelot", ""), + &Room::join(&code(7, "ABCDEG"), ""), None, None, |_| {}, @@ -115,7 +120,7 @@ fn later_fields_and_kinds_are_skipped() { } ); - let room = Room::join("4-quiet-heron", ""); + let room = Room::join(&code(4, "QJETHR"), ""); let grace = Live::start(hello("Grace"), &room, None, None, |_| {}).unwrap(); // A later version: it greets, says something new, then where it is. let later = thread::spawn(move || { @@ -298,7 +303,7 @@ fn a_relay_numbers_a_code_and_burns_it_after_wrong_tries() { }); let host = Live::start( hello("Ada"), - &Room::share("violet-otter", ""), + &Room::share("ABCDEF", ""), None, Some(&url), |_| {}, @@ -312,8 +317,8 @@ fn a_relay_numbers_a_code_and_burns_it_after_wrong_tries() { assert!(Instant::now() < deadline, "no code"); thread::sleep(Duration::from_millis(20)); }; - let (number, words) = code.split_once('-').unwrap(); - assert_eq!(words, "violet-otter"); + let (number, secret) = super::code::parse(&code).unwrap(); + assert_eq!(secret, "ABCDEF"); let guest = Live::start( hello("Grace"), &Room::join(&code, ""), @@ -326,7 +331,7 @@ fn a_relay_numbers_a_code_and_burns_it_after_wrong_tries() { until(&guest, |peers| peers.len() == 1); let path = format!("/v1/room/code-{number}"); - let wrong = Room::join(&format!("{number}-violet-ocelot"), ""); + let wrong = Room::join(&self::code(number, "ABCDEG"), ""); // An end that typed a wrong code gives up at once; Mallory tries twice, and the second // wrong try burns the code. for _ in 0..2 { diff --git a/crates/notebook/src/live/words.txt b/crates/notebook/src/live/words.txt deleted file mode 100644 index b5d21df1340f8c8fc2aed10fa6c3ba2dbec5f1ce..0000000000000000000000000000000000000000 --- a/crates/notebook/src/live/words.txt +++ /dev/null @@ -1,1296 +0,0 @@ -acid -acorn -acre -acts -afar -affix -aged -agent -agile -aging -agony -ahead -aide -aids -aim -ajar -alarm -alias -alibi -alien -alike -alive -aloe -aloft -aloha -alone -amend -amino -ample -amuse -angel -anger -angle -ankle -apple -april -apron -aqua -area -arena -argue -arise -armed -armor -army -aroma -array -arson -art -ashen -ashes -atlas -atom -attic -audio -avert -avoid -awake -award -awoke -axis -bacon -badge -bagel -baggy -baked -baker -balmy -banjo -barge -barn -bash -basil -bask -batch -bath -baton -bats -blade -blank -blast -blaze -bleak -blend -bless -blimp -blink -bloat -blob -blog -blot -blunt -blurt -blush -boast -boat -body -boil -bok -bolt -boned -boney -bonus -bony -book -booth -boots -boss -botch -both -boxer -breed -bribe -brick -bride -brim -bring -brink -brisk -broad -broil -broke -brook -broom -brush -buck -bud -buggy -bulge -bulk -bully -bunch -bunny -bunt -bush -bust -busy -buzz -cable -cache -cadet -cage -cake -calm -cameo -canal -candy -cane -canon -cape -card -cargo -carol -carry -carve -case -cash -cause -cedar -chain -chair -chant -chaos -charm -chase -cheek -cheer -chef -chess -chest -chew -chief -chili -chill -chip -chomp -chop -chow -chuck -chump -chunk -churn -chute -cider -cinch -city -civic -civil -clad -claim -clamp -clap -clash -clasp -class -claw -clay -clean -clear -cleat -cleft -clerk -click -cling -clink -clip -cloak -clock -clone -cloth -cloud -clump -coach -coast -coat -cod -coil -coke -cola -cold -colt -coma -come -comic -comma -cone -cope -copy -coral -cork -cost -cot -couch -cough -cover -cozy -craft -cramp -crane -crank -crate -crave -crawl -crazy -creme -crepe -crept -crib -cried -crisp -crook -crop -cross -crowd -crown -crumb -crush -crust -cub -cult -cupid -cure -curl -curry -curse -curve -curvy -cushy -cut -cycle -dab -dad -daily -dairy -daisy -dance -dandy -darn -dart -dash -data -date -dawn -deaf -deal -dean -debit -debt -debug -decaf -decal -decay -deck -decor -decoy -deed -delay -denim -dense -dent -depth -derby -desk -dial -diary -dice -dig -dill -dime -dimly -diner -dingy -disco -dish -disk -ditch -ditzy -dizzy -dock -dodge -doing -doll -dome -donor -donut -dose -dot -dove -down -dowry -doze -drab -drama -drank -draw -dress -dried -drift -drill -drive -drone -droop -drove -drown -drum -dry -duck -duct -dude -dug -duke -duo -dusk -dust -duty -dwarf -dwell -eagle -early -earth -easel -east -eaten -eats -ebay -ebony -ebook -echo -edge -eel -eject -elbow -elder -elf -elk -elm -elope -elude -elves -email -emit -empty -emu -enter -entry -envoy -equal -erase -error -erupt -essay -etch -evade -even -evict -evil -evoke -exact -exit -fable -faced -fact -fade -fall -false -fancy -fang -fax -feast -feed -femur -fence -fend -ferry -fetal -fetch -fever -fiber -fifth -fifty -film -filth -final -finch -fit -five -flag -flaky -flame -flap -flask -fled -flick -fling -flint -flip -flirt -float -flock -flop -floss -flyer -foam -foe -fog -foil -folic -folk -food -fool -found -fox -foyer -frail -frame -fray -fresh -fried -frill -frisk -from -front -frost -froth -frown -froze -fruit -gag -gains -gala -game -gap -gas -gave -gear -gecko -geek -gem -genre -gift -gig -gills -given -giver -glad -glass -glide -gloss -glove -glow -glue -goal -going -golf -gong -good -gooey -goofy -gore -gown -grab -grain -grant -grape -graph -grasp -grass -grave -gravy -gray -green -greet -grew -grid -grief -grill -grip -grit -groom -grope -growl -grub -grunt -guide -gulf -gulp -gummy -guru -gush -gut -guy -habit -half -halo -halt -happy -harm -hash -hasty -hatch -hate -haven -hazel -hazy -heap -heat -heave -hedge -hefty -help -herbs -hers -hub -hug -hula -hull -human -humid -hump -hung -hunk -hunt -hurry -hurt -hush -hut -ice -icing -icon -icy -igloo -image -ion -iron -islam -issue -item -ivory -ivy -jab -jam -jaws -jazz -jeep -jelly -jet -jiffy -job -jog -jolly -jolt -jot -joy -judge -juice -juicy -july -jumbo -jump -junky -juror -jury -keep -keg -kept -kick -kilt -king -kite -kitty -kiwi -knee -knelt -koala -kung -ladle -lady -lair -lake -lance -land -lapel -large -lash -lasso -last -latch -late -lazy -left -legal -lemon -lend -lens -lent -level -lever -lid -life -lift -lilac -lily -limb -limes -line -lint -lion -lip -list -lived -liver -lunar -lunch -lung -lurch -lure -lurk -lying -lyric -mace -maker -malt -mama -mango -manor -many -map -march -mardi -marry -mash -match -mate -math -moan -mocha -moist -mold -mom -moody -mop -morse -most -motor -motto -mount -mouse -mousy -mouth -move -movie -mower -mud -mug -mulch -mule -mull -mumbo -mummy -mural -muse -music -musky -mute -nacho -nag -nail -name -nanny -nap -navy -near -neat -neon -nerd -nest -net -next -niece -ninth -nutty -oak -oasis -oat -ocean -oil -old -olive -omen -onion -only -ooze -opal -open -opera -opt -otter -ouch -ounce -outer -oval -oven -owl -ozone -pace -pagan -pager -palm -panda -panic -pants -panty -paper -park -party -pasta -patch -path -patio -payer -pecan -penny -pep -perch -perky -perm -pest -petal -petri -petty -photo -plank -plant -plaza -plead -plot -plow -pluck -plug -plus -poach -pod -poem -poet -pogo -point -poise -poker -polar -polio -polka -polo -pond -pony -poppy -pork -poser -pouch -pound -pout -power -prank -press -print -prior -prism -prize -probe -prong -proof -props -prude -prune -pry -pug -pull -pulp -pulse -puma -punch -punk -pupil -puppy -purr -purse -push -putt -quack -quake -query -quiet -quill -quilt -quit -quota -quote -rabid -race -rack -radar -radio -raft -rage -raid -rail -rake -rally -ramp -ranch -range -rank -rant -rash -raven -reach -react -ream -rebel -recap -relax -relay -relic -remix -repay -repel -reply -rerun -reset -rhyme -rice -rich -ride -rigid -rigor -rinse -riot -ripen -rise -risk -ritzy -rival -river -roast -robe -robin -rock -rogue -roman -romp -rope -rover -royal -ruby -rug -ruin -rule -runny -rush -rust -rut -sadly -sage -said -saint -salad -salon -salsa -salt -same -sandy -santa -satin -sauna -saved -savor -sax -say -scale -scam -scan -scare -scarf -scary -scoff -scold -scoop -scoot -scope -score -scorn -scout -scowl -scrap -scrub -scuba -scuff -sect -sedan -self -send -sepia -serve -set -seven -shack -shade -shady -shaft -shaky -sham -shape -share -sharp -shed -sheep -sheet -shelf -shell -shine -shiny -ship -shirt -shock -shop -shore -shout -shove -shown -showy -shred -shrug -shun -shush -shut -shy -sift -silk -silly -silo -sip -siren -sixth -size -skate -skew -skid -skier -skies -skip -skirt -skit -sky -slab -slack -slain -slam -slang -slash -slate -slaw -sled -sleek -sleep -sleet -slept -slice -slick -slimy -sling -slip -slit -slob -slot -slug -slum -slurp -slush -small -smash -smell -smile -smirk -smog -snack -snap -snare -snarl -sneak -sneer -sniff -snore -snort -snout -snowy -snub -snuff -speak -speed -spend -spent -spew -spied -spill -spiny -spoil -spoke -spoof -spool -spoon -sport -spot -spout -spray -spree -spur -squad -squat -squid -stack -staff -stage -stain -stall -stamp -stand -stank -stark -start -stash -state -stays -steam -steep -stem -step -stew -stick -sting -stir -stock -stole -stomp -stony -stood -stool -stoop -stop -storm -stout -stove -straw -stray -strut -stuck -stud -stuff -stump -stung -stunt -suds -sugar -sulk -surf -sushi -swab -swan -swarm -sway -swear -sweat -sweep -swell -swept -swim -swing -swipe -swirl -swoop -swore -syrup -tacky -taco -tag -take -tall -talon -tamer -tank -taper -taps -tarot -tart -task -taste -tasty -taunt -thank -thaw -theft -theme -thigh -thing -think -thong -thorn -those -throb -thud -thumb -thump -thus -tiara -tidal -tidy -tiger -tile -tilt -tint -tiny -trace -track -trade -train -trait -trap -trash -tray -treat -tree -trek -trend -trial -tribe -trick -trio -trout -truce -truck -trump -trunk -try -tug -tulip -tummy -turf -tusk -tutor -tutu -tux -tweak -tweet -twice -twine -twins -twirl -twist -uncle -uncut -undo -unify -union -unit -untie -upon -upper -urban -used -user -usher -utter -value -vapor -vegan -venue -verse -vest -veto -vice -video -view -viral -virus -visa -visor -vixen -vocal -voice -void -volt -voter -vowel -wad -wafer -wager -wages -wagon -wake -walk -wand -wasp -watch -water -wavy -wheat -whiff -whole -whoop -wick -widen -widow -width -wife -wifi -wilt -wimp -wind -wing -wink -wipe -wired -wiry -wise -wish -wispy -wok -wolf -womb -wool -woozy -word -work -worry -wound -woven -wrath -wreck -wrist -xerox -yahoo -yam -yard -year -yeast -yelp -yield -yarn -yodel -yoga -yoyo -yummy -zebra -zero -zesty -zippy -zone -zoom diff --git a/crates/notebook/tests/live_share.rs b/crates/notebook/tests/live_share.rs index f9b571d6a424c5eab94916dde3e9e7b0df90e067..7903784fbed1f62a5c482292e1bf8c1c57aa261b 100644 --- a/crates/notebook/tests/live_share.rs +++ b/crates/notebook/tests/live_share.rs @@ -37,6 +37,12 @@ fn relay(config: relay::server::Config) -> String { url } +/// Another secret than `secret`, as a guess makes one. +fn mistaken(secret: &str) -> String { + let first = if secret.starts_with('A') { 'B' } else { 'A' }; + format!("{first}{}", &secret[1..]) +} + fn hello(name: &str) -> Hello { Hello::new(name.into(), None).unwrap() } @@ -338,13 +344,24 @@ fn wrong_codes_are_refused_and_counted() { let sharing = Sharing::new("").unwrap(); let host = host(&folder, &directory.path().join("host"), &sharing, &url); let code = code(&host); - let number = code.split('-').next().unwrap(); - let wrong = format!("{number}-violet-ocelot"); + let (number, secret) = notebook::live::code::parse(&code).unwrap(); + let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap(); let join = |code: &str| share::join(hello("Mallory"), code, "", None, Some(&url)); assert_eq!(join("not a code").unwrap_err(), Refusal::Malformed); + // A symbol mistyped fails its check here, spending none of the two tries before a burn. + let typo = format!( + "{}{}", + if code.starts_with('7') { '8' } else { '7' }, + &code[1..] + ); + assert_eq!(join(&typo).unwrap_err(), Refusal::Malformed); + assert_eq!( + join(&code.to_lowercase().replace('-', " ")).map(|_| ()), + Ok(()) + ); assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong); assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong); - // The code burned, and the host shares new words, under a number of their own. + // The code burned, and the host shares a new secret, under a number of its own. until("the code never changed", || { host.code() .is_some_and(|now| now != code && share::code(&now).is_some()) @@ -354,11 +371,9 @@ fn wrong_codes_are_refused_and_counted() { join(&code).unwrap_err(), Refusal::Expired | Refusal::NoOne )); - let number = fresh.split('-').next().unwrap(); - assert_eq!( - join(&format!("{number}-violet-ocelot")).unwrap_err(), - Refusal::Wrong - ); + let (number, secret) = notebook::live::code::parse(&fresh).unwrap(); + let wrong = notebook::live::code::format(number, &mistaken(&secret)).unwrap(); + assert_eq!(join(&wrong).unwrap_err(), Refusal::Wrong); // A third wrong code in a minute locks this network out, even from the right code. assert!(matches!( join(&fresh).unwrap_err(), diff --git a/crates/snowbound/src/share.rs b/crates/snowbound/src/share.rs index 2b5a7626166ef4eecd76dfe0a80a5f6422295100..240a4cf730deadeb1c96428b375643dea73a5b6b 100644 --- a/crates/snowbound/src/share.rs +++ b/crates/snowbound/src/share.rs @@ -61,7 +61,7 @@ enum Status { /// What to tell someone whose code didn't open a notebook. fn refusal(refusal: &Refusal) -> String { match refusal { - Refusal::Malformed => "Enter the code as it was given, like 412-violet-otter.".into(), + Refusal::Malformed => "Check the code. It looks like 7KQ-4MZ-9XR.".into(), Refusal::Wrong => "That code or password doesn’t open a notebook. Check it with the \ person sharing." .into(), @@ -449,7 +449,7 @@ impl State { text(ui, "what", "Enter the code from the person sharing.", false); labelled(ui, "Code:", |ui| { let spec = field_spec(ui); - ui::text_field(ui, code_field(), &mut dialog.code, "412-violet-otter", spec); + ui::text_field(ui, code_field(), &mut dialog.code, "7KQ-4MZ-9XR", spec); if let Some(node) = ui.access(code_field()) { node.set_label("Code"); } -- 2.54.0