diff --git a/api/src/email.rs b/api/src/email.rs index 99916ba..a41beba 100644 --- a/api/src/email.rs +++ b/api/src/email.rs @@ -24,17 +24,13 @@ fn brand_mark(brand: &str, accent: &str, logo: &str) -> String { } fn render(brand: &str, accent: &str, logo: &str, title: &str, message: &str, code: &str, note: &str) -> String { - // `message` carries the account name (attacker-influenceable). Substitute every - // OTHER slot first and `{{message}}` LAST, so a message that literally contains - // `{{code}}`/`{{note}}` (an account named that) is inserted verbatim rather than - // splicing in the real code. BASE.replace("{{brand_mark}}", &brand_mark(brand, accent, logo)) .replace("{{brand}}", &escape(brand)) .replace("{{accent}}", &escape(accent)) .replace("{{title}}", &escape(title)) + .replace("{{message}}", &escape(message)) .replace("{{code}}", &escape(code)) .replace("{{note}}", &escape(note)) - .replace("{{message}}", &escape(message)) } pub fn reset(brand: &str, accent: &str, logo: &str, account: &str, code: &str, lang: &str) -> Mail { diff --git a/api/src/lib.rs b/api/src/lib.rs index e93b8c2..131d2d9 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -497,29 +497,13 @@ pub fn render(lang: &str, msgid: &str, args: &[(&str, String)]) -> String { .and_then(|m| m.get(msgid)) .map(String::as_str) .unwrap_or(msgid); - if args.is_empty() || !template.contains('{') { + if args.is_empty() { return template.to_string(); } - // Single left-to-right pass: a substituted value is copied to the output and - // never re-scanned, so a value that itself contains `{name}` (e.g. a nick like - // "{game}") can't trigger a second substitution. An unknown `{x}` (no matching - // arg, e.g. the literal `{ON|OFF}`) is left verbatim, as before. - let mut out = String::with_capacity(template.len() + 16); - let mut rest = template; - while let Some(open) = rest.find('{') { - out.push_str(&rest[..open]); - let Some(rel_close) = rest[open..].find('}') else { - break; // unbalanced '{' — emit the remainder verbatim below - }; - let close = open + rel_close; - let name = &rest[open + 1..close]; - match args.iter().find(|(n, _)| *n == name) { - Some((_, val)) => out.push_str(val), - None => out.push_str(&rest[open..=close]), - } - rest = &rest[close + 1..]; + let mut out = template.to_string(); + for (name, val) in args { + out = out.replace(&format!("{{{name}}}"), val); } - out.push_str(rest); out } diff --git a/lang/de.json b/lang/de.json index 4f0d175..188a18d 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1334,24 +1334,5 @@ "You didn't identify in time; you've been renamed.": "Du hast dich nicht rechtzeitig identifiziert; du wurdest umbenannt.", "Please mind the channel rules — {reason} Next time you'll be kicked.": "Bitte beachte die Kanalregeln — {reason} Beim nächsten Mal wirst du gekickt.", "Access denied — {what} is for services operators.": "Zugriff verweigert — {what} ist für Services-Operatoren.", - "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 ist kein gültiger Nickname.", - "Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Die Kanäle, die du als \u0002{account}\u0002 registriert hattest, wurden freigegeben: {list}.", - "Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Dein Konto \u0002{account}\u0002 {reason}. Du wurdest abgemeldet.", - "was dropped": "wurde gelöscht", - "collided with another network and no longer belongs to you": "kollidierte mit einem anderen Netzwerk und gehört dir nicht mehr", - "was suspended": "wurde gesperrt", - "expired after a long period of inactivity": "ist nach langer Inaktivität abgelaufen", - "Your password for \u0002{account}\u0002 has been changed.": "Dein Passwort für \u0002{account}\u0002 wurde geändert.", - "That's a lot of dice, stopping the repeats here.": "Das sind viele Würfel, ich stoppe die Wiederholungen hier.", - "trailing characters in expression": "überzählige Zeichen im Ausdruck", - "missing closing parenthesis": "schließende Klammer fehlt", - "expected a value": "Wert erwartet", - "nothing to roll": "nichts zum Würfeln", - "that expression is too long": "dieser Ausdruck ist zu lang", - "that doesn't come out to a real number": "das ergibt keine reelle Zahl", - "division by zero": "Division durch null", - "modulo by zero": "Modulo durch null", - "that expression rolls too many dice": "dieser Ausdruck würfelt zu viele Würfel", - "hypot() takes two arguments": "hypot() nimmt zwei Argumente", - "pow() takes two arguments": "pow() nimmt zwei Argumente" + "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 ist kein gültiger Nickname." } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 184e923..e623a36 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1334,24 +1334,5 @@ "You didn't identify in time; you've been renamed.": "No te identificaste a tiempo; se te cambió el nombre.", "Please mind the channel rules — {reason} Next time you'll be kicked.": "Prestá atención a las reglas del canal — {reason} La próxima vez te van a expulsar.", "Access denied — {what} is for services operators.": "Acceso denegado — {what} es solo para operadores de los servicios.", - "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 no es un nick válido.", - "Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Los canales que habías registrado como \u0002{account}\u0002 fueron liberados: {list}.", - "Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Tu cuenta \u0002{account}\u0002 {reason}. Se cerró tu sesión.", - "was dropped": "fue eliminada", - "collided with another network and no longer belongs to you": "entró en conflicto con otra red y ya no te pertenece", - "was suspended": "fue suspendida", - "expired after a long period of inactivity": "caducó tras un largo período de inactividad", - "Your password for \u0002{account}\u0002 has been changed.": "Tu contraseña para \u0002{account}\u0002 fue cambiada.", - "That's a lot of dice, stopping the repeats here.": "Son un montón de dados, corto las repeticiones acá.", - "trailing characters in expression": "caracteres de más en la expresión", - "missing closing parenthesis": "falta el paréntesis de cierre", - "expected a value": "se esperaba un valor", - "nothing to roll": "nada para tirar", - "that expression is too long": "esa expresión es demasiado larga", - "that doesn't come out to a real number": "eso no da un número real", - "division by zero": "división por cero", - "modulo by zero": "módulo por cero", - "that expression rolls too many dice": "esa expresión tira demasiados dados", - "hypot() takes two arguments": "hypot() toma dos argumentos", - "pow() takes two arguments": "pow() toma dos argumentos" + "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 no es un nick válido." } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 58b77b2..4352991 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1334,24 +1334,5 @@ "You didn't identify in time; you've been renamed.": "No te identificaste a tiempo; se te ha cambiado el nombre.", "Please mind the channel rules — {reason} Next time you'll be kicked.": "Ten en cuenta las reglas del canal — {reason} La próxima vez se te expulsará.", "Access denied — {what} is for services operators.": "Acceso denegado — {what} es solo para operadores de servicios.", - "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 no es un nick válido.", - "Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Los canales que habías registrado como \u0002{account}\u0002 han sido liberados: {list}.", - "Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Tu cuenta \u0002{account}\u0002 {reason}. Se ha cerrado tu sesión.", - "was dropped": "ha sido eliminada", - "collided with another network and no longer belongs to you": "entró en conflicto con otra red y ya no te pertenece", - "was suspended": "ha sido suspendida", - "expired after a long period of inactivity": "ha caducado tras un largo periodo de inactividad", - "Your password for \u0002{account}\u0002 has been changed.": "Tu contraseña para \u0002{account}\u0002 ha sido cambiada.", - "That's a lot of dice, stopping the repeats here.": "Son muchos dados, detengo las repeticiones aquí.", - "trailing characters in expression": "caracteres sobrantes en la expresión", - "missing closing parenthesis": "falta el paréntesis de cierre", - "expected a value": "se esperaba un valor", - "nothing to roll": "nada que lanzar", - "that expression is too long": "esa expresión es demasiado larga", - "that doesn't come out to a real number": "eso no da un número real", - "division by zero": "división por cero", - "modulo by zero": "módulo por cero", - "that expression rolls too many dice": "esa expresión lanza demasiados dados", - "hypot() takes two arguments": "hypot() toma dos argumentos", - "pow() takes two arguments": "pow() toma dos argumentos" + "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 no es un nick válido." } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index 39b2f79..26eaec3 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1334,24 +1334,5 @@ "You didn't identify in time; you've been renamed.": "Vous ne vous êtes pas identifié à temps ; vous avez été renommé.", "Please mind the channel rules — {reason} Next time you'll be kicked.": "Veuillez respecter les règles du canal — {reason} La prochaine fois, vous serez expulsé.", "Access denied — {what} is for services operators.": "Accès refusé — {what} est réservé aux opérateurs des services.", - "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 n'est pas un pseudo valide.", - "Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Les salons que vous aviez enregistrés en tant que \u0002{account}\u0002 ont été libérés : {list}.", - "Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Votre compte \u0002{account}\u0002 {reason}. Vous avez été déconnecté.", - "was dropped": "a été supprimé", - "collided with another network and no longer belongs to you": "est entré en collision avec un autre réseau et ne vous appartient plus", - "was suspended": "a été suspendu", - "expired after a long period of inactivity": "a expiré après une longue période d'inactivité", - "Your password for \u0002{account}\u0002 has been changed.": "Votre mot de passe pour \u0002{account}\u0002 a été modifié.", - "That's a lot of dice, stopping the repeats here.": "Ça fait beaucoup de dés, j'arrête les répétitions ici.", - "trailing characters in expression": "caractères en trop dans l'expression", - "missing closing parenthesis": "parenthèse fermante manquante", - "expected a value": "valeur attendue", - "nothing to roll": "rien à lancer", - "that expression is too long": "cette expression est trop longue", - "that doesn't come out to a real number": "cela ne donne pas un nombre réel", - "division by zero": "division par zéro", - "modulo by zero": "modulo par zéro", - "that expression rolls too many dice": "cette expression lance trop de dés", - "hypot() takes two arguments": "hypot() prend deux arguments", - "pow() takes two arguments": "pow() prend deux arguments" + "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 n'est pas un pseudo valide." } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index 2368d0f..1a815da 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1334,24 +1334,5 @@ "You didn't identify in time; you've been renamed.": "Você não se identificou a tempo; seu apelido foi alterado.", "Please mind the channel rules — {reason} Next time you'll be kicked.": "Respeite as regras do canal — {reason} Da próxima vez você será expulso.", "Access denied — {what} is for services operators.": "Acesso negado — {what} é para operadores de serviços.", - "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 não é um apelido válido.", - "Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Os canais que você registrou como \u0002{account}\u0002 foram liberados: {list}.", - "Your account \u0002{account}\u0002 {reason}. You have been logged out.": "Sua conta \u0002{account}\u0002 {reason}. Sua sessão foi encerrada.", - "was dropped": "foi removida", - "collided with another network and no longer belongs to you": "entrou em conflito com outra rede e não pertence mais a você", - "was suspended": "foi suspensa", - "expired after a long period of inactivity": "expirou após um longo período de inatividade", - "Your password for \u0002{account}\u0002 has been changed.": "Sua senha para \u0002{account}\u0002 foi alterada.", - "That's a lot of dice, stopping the repeats here.": "São muitos dados, parando as repetições aqui.", - "trailing characters in expression": "caracteres a mais na expressão", - "missing closing parenthesis": "falta o parêntese de fechamento", - "expected a value": "esperava um valor", - "nothing to roll": "nada para rolar", - "that expression is too long": "essa expressão é longa demais", - "that doesn't come out to a real number": "isso não resulta em um número real", - "division by zero": "divisão por zero", - "modulo by zero": "módulo por zero", - "that expression rolls too many dice": "essa expressão rola dados demais", - "hypot() takes two arguments": "hypot() recebe dois argumentos", - "pow() takes two arguments": "pow() recebe dois argumentos" + "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 não é um apelido válido." } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index 43a88f4..1082806 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1334,24 +1334,5 @@ "You didn't identify in time; you've been renamed.": "Não se identificou a tempo; o seu nome foi alterado.", "Please mind the channel rules — {reason} Next time you'll be kicked.": "Respeite as regras do canal — {reason} Da próxima vez será expulso.", "Access denied — {what} is for services operators.": "Acesso negado — {what} é para operadores de serviços.", - "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 não é uma alcunha válida.", - "Channels you had registered as \u0002{account}\u0002 were released: {list}.": "Os canais que tinha registado como \u0002{account}\u0002 foram libertados: {list}.", - "Your account \u0002{account}\u0002 {reason}. You have been logged out.": "A sua conta \u0002{account}\u0002 {reason}. A sua sessão foi terminada.", - "was dropped": "foi removida", - "collided with another network and no longer belongs to you": "entrou em conflito com outra rede e já não lhe pertence", - "was suspended": "foi suspensa", - "expired after a long period of inactivity": "expirou após um longo período de inatividade", - "Your password for \u0002{account}\u0002 has been changed.": "A sua palavra-passe para \u0002{account}\u0002 foi alterada.", - "That's a lot of dice, stopping the repeats here.": "São muitos dados, a parar as repetições aqui.", - "trailing characters in expression": "caracteres a mais na expressão", - "missing closing parenthesis": "falta o parêntese de fecho", - "expected a value": "esperava um valor", - "nothing to roll": "nada para lançar", - "that expression is too long": "essa expressão é demasiado longa", - "that doesn't come out to a real number": "isso não dá um número real", - "division by zero": "divisão por zero", - "modulo by zero": "módulo por zero", - "that expression rolls too many dice": "essa expressão lança demasiados dados", - "hypot() takes two arguments": "hypot() recebe dois argumentos", - "pow() takes two arguments": "pow() recebe dois argumentos" + "\u0002{nick}\u0002 isn't a valid nickname.": "\u0002{nick}\u0002 não é uma alcunha válida." } \ No newline at end of file diff --git a/modules/diceserv/src/roll.rs b/modules/diceserv/src/roll.rs index 0adfd3b..e0a3e69 100644 --- a/modules/diceserv/src/roll.rs +++ b/modules/diceserv/src/roll.rs @@ -11,7 +11,7 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, exte let input = rest.join(" "); let input = input.trim(); if input.is_empty() { - ctx.notice(me, from.uid, t!(ctx, "Give me something to roll, e.g. \x022d6+3\x02.")); + ctx.notice(me, from.uid, "Give me something to roll, e.g. \x022d6+3\x02."); return; } // `N~expr` repeats the roll N times. @@ -44,15 +44,12 @@ pub fn handle(me: &str, from: &Sender, rest: &[&str], ctx: &mut ServiceCtx, exte // `25~99999d1+…` can't multiply the per-roll cap into tens of millions. if spent >= expr::MAX_TOTAL { if i + 1 < times { - ctx.notice(me, from.uid, t!(ctx, "That's a lot of dice, stopping the repeats here.")); + ctx.notice(me, from.uid, "That's a lot of dice — stopping the repeats here."); } break; } } Err(why) => { - // The parser returns its diagnostic in English; localize the static - // fragments through the catalog (interpolated ones fall back verbatim). - let why = echo_api::render(ctx.lang(), &why, &[]); ctx.notice(me, from.uid, t!(ctx, "I couldn't roll \x02{body}\x02: {why}.", body = body, why = why)); return; // the whole batch shares the expression, so it'll fail the same way } diff --git a/modules/gameserv/src/lib.rs b/modules/gameserv/src/lib.rs index 982c58f..2b1e020 100644 --- a/modules/gameserv/src/lib.rs +++ b/modules/gameserv/src/lib.rs @@ -463,11 +463,9 @@ fn push_stats(counters: &[(String, u64)], account: &str, to_nick: &str, me: &str _ => read_stats(counters, gt, account), }; let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, gt.wire(), s.rank(), s.points(), s.wins, s.losses, s.draws); - // Ranked stats are PUBLIC leaderboard data and are also pushed to a viewer - // who queried someone ELSE (STATS ), so `to_nick` is not the subject - // account — don't apply the board-state account guard here (that guard is - // only for the private mid-game board in push_one). - push_tag(me, net, to_nick, account, false, &payload, ctx); + // push_stats is only called for a registered side (a_reg/b_reg), so the + // account guard in push_tag always applies here. + push_tag(me, net, to_nick, account, true, &payload, ctx); } } diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 5ae7994..461006c 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -842,9 +842,8 @@ impl Db { } /// Channels entering the warning window, paired with their founder's email - /// (skipped when the founder has none) and preferred language. Returns - /// (channel, email, seconds left, founder language). - pub fn channels_to_warn(&self, now: u64, ttl: u64, lead: u64) -> Vec<(String, String, u64, String)> { + /// (skipped when the founder has none). Returns (channel, email, seconds left). + pub fn channels_to_warn(&self, now: u64, ttl: u64, lead: u64) -> Vec<(String, String, u64)> { let floor = ttl.saturating_sub(lead); self.channels .values() @@ -854,10 +853,8 @@ impl Db { if idle <= floor || idle > ttl { return None; } - let acc = self.accounts.get(&key(&c.founder))?; - let email = acc.email.clone()?; - let lang = acc.language.clone().unwrap_or_else(|| self.default_language()); - Some((c.name.clone(), email, ttl - idle, lang)) + let email = self.accounts.get(&key(&c.founder)).and_then(|a| a.email.clone())?; + Some((c.name.clone(), email, ttl - idle)) }) .collect() } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index e9d2cb9..9d21cfc 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -727,8 +727,11 @@ impl Engine { self.logout_uid(&uid, account, reason); if !orphaned.is_empty() { if let Some(ns) = &ns { - let text = echo_api::render(&self.lang_for_uid(&uid), "Channels you had registered as \x02{account}\x02 were released: {list}.", &[("account", account.to_string()), ("list", orphaned.join(", "))]); - self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid.clone(), text }); + self.emit_irc(NetAction::Notice { + from: ns.clone(), + to: uid, + text: format!("Channels you had registered as \x02{account}\x02 were released: {}.", orphaned.join(", ")), + }); } } } @@ -770,10 +773,11 @@ impl Engine { self.network.clear_account(uid); self.emit_irc(NetAction::Metadata { target: uid.to_string(), key: "accountname".to_string(), value: String::new() }); if let Some(ns) = self.nick_service.clone() { - let lang = self.lang_for_uid(uid); - let reason = echo_api::render(&lang, reason, &[]); - let text = echo_api::render(&lang, "Your account \x02{account}\x02 {reason}. You have been logged out.", &[("account", account.to_string()), ("reason", reason)]); - self.emit_irc(NetAction::Notice { from: ns, to: uid.to_string(), text }); + self.emit_irc(NetAction::Notice { + from: ns, + to: uid.to_string(), + text: format!("Your account \x02{account}\x02 {reason}. You have been logged out."), + }); } } @@ -855,8 +859,8 @@ impl Engine { } } if let Some(ttl) = self.channel_ttl { - for (channel, email, left, lang) in self.db.channels_to_warn(now, ttl, lead) { - let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Channel, &channel, &human_duration(left), &lang); + for (channel, email, left) in self.db.channels_to_warn(now, ttl, lead) { + let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Channel, &channel, &human_duration(left), &self.db.default_language()); self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) }); self.db.mark_channel_warned(&channel); } diff --git a/src/engine/register.rs b/src/engine/register.rs index c8090ca..9105ca1 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -356,10 +356,9 @@ impl Engine { // Commit a password change the link layer derived off-thread, then notice the user. pub fn complete_password_change(&mut self, account: &str, creds: Option, agent: &str, uid: &str) -> Vec { - let lang = self.lang_for_account(account); let text = match creds.and_then(|c| self.db.set_credentials(account, c).ok()) { - Some(()) => echo_api::render(&lang, "Your password for \x02{account}\x02 has been changed.", &[("account", account.to_string())]), - None => echo_api::render(&lang, "Sorry, that didn't work. Please try again in a moment.", &[]), + Some(()) => format!("Your password for \x02{account}\x02 has been changed."), + None => "Sorry, that didn't work. Please try again in a moment.".to_string(), }; vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }] } diff --git a/src/gossip.rs b/src/gossip.rs index 0d2ef2b..f967b26 100644 --- a/src/gossip.rs +++ b/src/gossip.rs @@ -51,17 +51,12 @@ fn make_nonce() -> String { STANDARD.encode(b) } -// Proof of holding the shared secret: HMAC-SHA256(secret, direction ‖ nonce). The +// Proof of holding the shared secret: HMAC-SHA256(secret, the peer's nonce). The // secret itself never crosses the wire — the peer verifies this against the nonce -// it chose. `direction` ("L" for the listener's proof, "D" for the dialer's) is -// mixed in so the two sides' proofs are over DIFFERENT inputs: a peer can neither -// reflect our own nonce+proof back to us, nor use a second connection as an oracle -// to have us sign the value it needs — both would need the other direction's -// label, which only the other role ever produces. -fn auth_proof(secret: &str, direction: &str, nonce: &str) -> String { +// it chose, so an eavesdropper (or a peer with the wrong secret) can't reproduce +// it and can't replay it on a later connection (fresh nonce each time). +fn auth_proof(secret: &str, nonce: &str) -> String { let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length"); - mac.update(direction.as_bytes()); - mac.update(b"\0"); mac.update(nonce.as_bytes()); STANDARD.encode(mac.finalize().into_bytes()) } @@ -146,14 +141,11 @@ async fn listen(bind: String, engine: Shared, secret: String, origin: String, ou tokio::spawn(async move { let _permit = permit; // released when the session ends let served = match acceptor { - // Bound the TLS handshake too, so a peer that opens the socket but - // never finishes negotiating can't pin its session permit forever. - Some(acc) => match tokio::time::timeout(HANDSHAKE_TIMEOUT, acc.accept(stream)).await { - Ok(Ok(tls)) => session(tls, engine, secret, origin, outbound, true).await, - Ok(Err(e)) => return tracing::debug!(%e, %addr, "gossip tls accept failed"), - Err(_) => return tracing::debug!(%addr, "gossip tls accept timed out"), + Some(acc) => match acc.accept(stream).await { + Ok(tls) => session(tls, engine, secret, origin, outbound).await, + Err(e) => return tracing::debug!(%e, %addr, "gossip tls accept failed"), }, - None => session(stream, engine, secret, origin, outbound, true).await, + None => session(stream, engine, secret, origin, outbound).await, }; if let Err(e) = served { tracing::debug!(%e, "gossip session ended"); @@ -171,12 +163,12 @@ async fn dial(peer: Peer, engine: Shared, secret: String, origin: String, outbou let served = match &connector { Some(conn) => match ServerName::try_from(peer.name.clone()) { Ok(name) => match conn.connect(name, stream).await { - Ok(tls) => session(tls, engine.clone(), secret.clone(), origin.clone(), outbound.clone(), false).await, + Ok(tls) => session(tls, engine.clone(), secret.clone(), origin.clone(), outbound.clone()).await, Err(e) => Err(anyhow::anyhow!("tls connect: {e}")), }, Err(e) => Err(anyhow::anyhow!("bad peer TLS name {:?}: {e}", peer.name)), }, - None => session(stream, engine.clone(), secret.clone(), origin.clone(), outbound.clone(), false).await, + None => session(stream, engine.clone(), secret.clone(), origin.clone(), outbound.clone()).await, }; if let Err(e) = served { tracing::debug!(%e, addr = %peer.addr, "gossip session ended"); @@ -235,7 +227,7 @@ async fn read_handshake(reader: &mut R) -> Option } // One peer connection: authenticate, then run anti-entropy until it drops. -async fn session(stream: S, engine: Shared, secret: String, origin: String, outbound: Outbound, listener: bool) -> anyhow::Result<()> +async fn session(stream: S, engine: Shared, secret: String, origin: String, outbound: Outbound) -> anyhow::Result<()> where S: AsyncRead + AsyncWrite + Send + 'static, { @@ -254,12 +246,10 @@ where // Handshake: a mutual challenge-response over the shared secret, so the secret // itself never crosses the wire. Each side sends a random nonce, then proves it - // holds the secret by returning HMAC(secret, its-role ‖ the peer's nonce); each - // verifies the peer's proof against the nonce it chose and the peer's role. The - // per-role label (listener "L" vs dialer "D") is what stops a reflection or - // oracle attack — without it, a peer with no secret could bounce our own - // nonce+proof back and authenticate. A fresh nonce per link stops replay. - let (my_dir, peer_dir) = if listener { ("L", "D") } else { ("D", "L") }; + // holds the secret by returning HMAC(secret, the peer's nonce); each verifies + // the peer's proof against the nonce it chose. A wrong secret (or an + // eavesdropper) can't produce a valid proof, and a fresh nonce per link stops + // replay. let my_nonce = make_nonce(); let _ = send(&tx, &Msg::Hello { origin, nonce: my_nonce.clone() }).await; let peer_nonce = match read_handshake(&mut reader).await { @@ -269,15 +259,9 @@ where anyhow::bail!("peer failed to complete the handshake"); } }; - // A peer echoing our own nonce back has nothing to prove — refuse it outright - // (the direction labels already defeat reflection; this is belt-and-braces). - if peer_nonce == my_nonce { - writer.abort(); - anyhow::bail!("gossip peer reused our nonce"); - } - let _ = send(&tx, &Msg::Auth { proof: auth_proof(&secret, my_dir, &peer_nonce) }).await; + let _ = send(&tx, &Msg::Auth { proof: auth_proof(&secret, &peer_nonce) }).await; match read_handshake(&mut reader).await { - Some(Msg::Auth { proof }) if ct_str_eq(&proof, &auth_proof(&secret, peer_dir, &my_nonce)) => {} + Some(Msg::Auth { proof }) if ct_str_eq(&proof, &auth_proof(&secret, &my_nonce)) => {} _ => { writer.abort(); anyhow::bail!("bad gossip handshake"); @@ -378,15 +362,6 @@ mod tests { use crate::engine::db::Db; use echo_nickserv::NickServ; - // The listener's and the dialer's proof over the same nonce MUST differ, so a - // secretless peer can't reflect one side's proof (or oracle it on a second - // connection) as the other side's expected value. - #[test] - fn handshake_proof_is_direction_separated() { - assert_ne!(auth_proof("s3cret", "L", "abc"), auth_proof("s3cret", "D", "abc")); - assert_eq!(auth_proof("s3cret", "L", "abc"), auth_proof("s3cret", "L", "abc")); - } - fn engine(origin: &str, tag: &str) -> (Shared, Outbound) { let path = std::env::temp_dir().join(format!("echo-gossip-{tag}.jsonl")); let _ = std::fs::remove_file(&path); @@ -407,8 +382,8 @@ mod tests { a.lock().await.test_register("alice"); let (ca, cb) = tokio::io::duplex(64 * 1024); - let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx, true)); - let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx, false)); + let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx)); + let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx)); let mut converged = false; for _ in 0..100 { @@ -431,8 +406,8 @@ mod tests { let (b, btx) = engine("B", "push-b"); let (ca, cb) = tokio::io::duplex(64 * 1024); - let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx, true)); - let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx, false)); + let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx)); + let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx)); tokio::time::sleep(Duration::from_millis(200)).await; // let both subscribe a.lock().await.test_register("late"); @@ -460,8 +435,8 @@ mod tests { b.lock().await.test_register_pw("alice", "from-b"); let (ca, cb) = tokio::io::duplex(64 * 1024); - let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx, true)); - let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx, false)); + let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx)); + let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx)); let mut converged = false; for _ in 0..100 { @@ -487,8 +462,8 @@ mod tests { a.lock().await.test_register_channel("#secret", "alice"); let (ca, cb) = tokio::io::duplex(64 * 1024); - let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx, true)); - let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx, false)); + let sa = tokio::spawn(session(ca, a.clone(), "s3cret".into(), "A".into(), atx)); + let sb = tokio::spawn(session(cb, b.clone(), "s3cret".into(), "B".into(), btx)); // Wait for the account to converge (proves the link works), then assert // the channel never crossed it. @@ -517,8 +492,8 @@ mod tests { a.lock().await.test_register("alice"); let (ca, cb) = tokio::io::duplex(64 * 1024); - let sa = tokio::spawn(session(ca, a.clone(), "right".into(), "A".into(), atx, true)); - let sb = tokio::spawn(session(cb, b.clone(), "wrong".into(), "B".into(), btx, false)); + let sa = tokio::spawn(session(ca, a.clone(), "right".into(), "A".into(), atx)); + let sb = tokio::spawn(session(cb, b.clone(), "wrong".into(), "B".into(), btx)); let _ = tokio::time::timeout(Duration::from_secs(1), async { let _ = sa.await; @@ -563,8 +538,8 @@ mod tests { let (sside, cside) = tokio::io::duplex(64 * 1024); let name = ServerName::try_from("echo").unwrap(); let (server, client) = tokio::join!(acceptor.accept(sside), connector.connect(name, cside)); - let sa = tokio::spawn(session(server.expect("tls accept"), a.clone(), "s3cret".into(), "A".into(), atx, true)); - let sb = tokio::spawn(session(client.expect("tls connect"), b.clone(), "s3cret".into(), "B".into(), btx, false)); + let sa = tokio::spawn(session(server.expect("tls accept"), a.clone(), "s3cret".into(), "A".into(), atx)); + let sb = tokio::spawn(session(client.expect("tls connect"), b.clone(), "s3cret".into(), "B".into(), btx)); let mut converged = false; for _ in 0..100 { diff --git a/src/link.rs b/src/link.rs index 6f74014..f01234f 100644 --- a/src/link.rs +++ b/src/link.rs @@ -55,19 +55,20 @@ fn redact(line: &str) -> Cow<'_, str> { let after = p + kw.len(); if let Some(c) = line[after..].find(" :") { let tstart = after + c + 2; - // Tokenise the trailer exactly like the dispatcher (`split_whitespace`), - // so a TAB or a leading/collapsed space can't sneak a credential past - // redaction while still reaching the command handler. - let mut words = line[tstart..].split_whitespace(); - let mut cmd = words.next().unwrap_or("").to_ascii_lowercase(); + let mut words = line[tstart..].split(' '); + let first = words.next().unwrap_or(""); + let mut cmd = first.to_ascii_lowercase(); + let mut argstart = tstart + first.len(); // "NS ..." / "NICKSERV ..." — unwrap one level. if matches!(cmd.as_str(), "ns" | "nickserv") { - cmd = words.next().unwrap_or("").to_ascii_lowercase(); + if let Some(w2) = words.next() { + cmd = w2.to_ascii_lowercase(); + argstart += 1 + w2.len(); + } } let is_set_password = cmd == "set" && words.next().map(|w| w.eq_ignore_ascii_case("password")).unwrap_or(false); - // Over-redact the whole trailer when the command is credential-bearing. - if SECRET_CMDS.contains(&cmd.as_str()) || is_set_password { + if (SECRET_CMDS.contains(&cmd.as_str()) || is_set_password) && argstart < line.len() { return format!("{} :[REDACTED]", line[..tstart].trim_end_matches(" :")).into(); } }