From f2fd80694dac6e0c7cdde881afafc622cab8ca3e Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 22:27:59 +0000 Subject: [PATCH] Redeem website login keycards over SASL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member already authenticated on tchatou.fr connects with a single-use kc_… keycard instead of their password (the m_apiauth module that used to validate it was retired in the Anope->echo cutover, so these logins had started failing as "wrong password"). The SASL PLAIN handler now recognises a kc_-prefixed credential and emits NetAction::DeferKeycard; the link layer redeems it off the engine lock — a localhost round-trip to Django's login-token endpoint via keycard::redeem — and completes the login exactly like a password auth. Config: [keycard] url + api_key; absent, kc_ credentials are simply not honoured. --- Cargo.lock | 278 +++++++++++++++++++++++++++ Cargo.toml | 1 + api/src/lib.rs | 5 + modules/protocol/inspircd/src/lib.rs | 2 +- src/config.rs | 14 ++ src/engine/sasl.rs | 19 +- src/keycard.rs | 27 +++ src/link.rs | 14 +- src/main.rs | 3 +- 9 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 src/keycard.rs diff --git a/Cargo.lock b/Cargo.lock index 854c1bf..257dd49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" @@ -273,6 +284,7 @@ dependencies = [ "echo-botserv", "echo-chanfix", "echo-chanserv", + "echo-debugserv", "echo-diceserv", "echo-example", "echo-groupserv", @@ -304,6 +316,7 @@ dependencies = [ "tonic-build", "tracing", "tracing-subscriber", + "ureq", ] [[package]] @@ -331,6 +344,13 @@ dependencies = [ "echo-api", ] +[[package]] +name = "echo-debugserv" +version = "0.0.1" +dependencies = [ + "echo-api", +] + [[package]] name = "echo-diceserv" version = "0.0.1" @@ -704,6 +724,109 @@ dependencies = [ "tracing", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -767,6 +890,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.33" @@ -890,6 +1019,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1336,6 +1474,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -1359,6 +1503,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1381,6 +1536,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1663,6 +1828,36 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "url", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" @@ -1787,6 +1982,35 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -1807,12 +2031,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index eecd30b..c9eca50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ axum = "0.7" axum-server = { version = "0.7", features = ["tls-rustls"] } prost = "0.13" tokio-stream = "0.1" +ureq = { version = "2", default-features = false } [build-dependencies] tonic-build = "0.12" diff --git a/api/src/lib.rs b/api/src/lib.rs index 693adb3..3d376a8 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -128,6 +128,11 @@ pub enum NetAction { // fetches `verifier` cheaply, runs verify_plain off-thread, then calls // Engine::complete_authenticate with the boolean result and `then`. DeferAuthenticate { verifier: String, password: String, then: AuthThen }, + // Internal only: redeem a website login keycard (`kc_…`) off-thread. The + // credential isn't a password, so it can't be SCRAM-verified; the link layer + // redeems `token` for `account` against Django's localhost login-token + // endpoint, then calls Engine::complete_authenticate with the result + `then`. + DeferKeycard { token: String, account: String, then: AuthThen }, // Internal only: send an email (plaintext + optional HTML). The link layer // pipes it to the configured mail command off-thread; never serialized. SendEmail { to: String, subject: String, text: String, html: Option }, diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index d985043..2348edb 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -396,7 +396,7 @@ impl Protocol for InspIrcd { NetAction::Squit { target, reason } => vec![self.sourced(format!("SQUIT {} :{}", target, reason))], NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles these before serialization. - NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::DeferAuthenticate { .. } | NetAction::SendEmail { .. } | NetAction::Shutdown { .. } => vec![], + NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } | NetAction::DeferAuthenticate { .. } | NetAction::DeferKeycard { .. } | NetAction::SendEmail { .. } | NetAction::Shutdown { .. } => vec![], }; // A trailing parameter can carry free-form text (message bodies, kick // reasons, topics, metadata). Strip CR/LF/NUL at this single choke-point diff --git a/src/config.rs b/src/config.rs index efcd552..33b0526 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,6 +41,20 @@ pub struct Config { // and pushes accounts in; IRC can only authenticate. #[serde(default)] pub auth: Option, + // Website single-use keycards (passwordless web login). Absent = keycard + // credentials (`kc_…` over SASL) are not honoured. + #[serde(default)] + pub keycard: Option, +} + +// Redeeming a website login keycard: a member already authenticated on the +// website connects with a one-time `kc_…` token instead of their password. We +// hand it to Django's localhost login-token endpoint, which redeems it and +// confirms the account. `url` is that endpoint; `api_key` matches its X-API-Key. +#[derive(Debug, Deserialize, Clone)] +pub struct Keycard { + pub url: String, + pub api_key: String, } // Account-authority configuration. diff --git a/src/engine/sasl.rs b/src/engine/sasl.rs index dc0c5aa..b27e989 100644 --- a/src/engine/sasl.rs +++ b/src/engine/sasl.rs @@ -59,13 +59,30 @@ impl Engine { // Decode, then defer the verify off the lock (the login // finish lands in Engine::complete_authenticate). match decode_plain(&response) { + // A website login keycard (`kc_…`), not a password: it can't be + // SCRAM-verified, so redeem it off-lock (link.rs) against Django. + Some((authcid, passwd)) if passwd.starts_with("kc_") => { + let account = match self.db.resolve_account(&authcid) { + Some(a) => a.to_string(), + None => authcid, + }; + vec![NetAction::DeferKeycard { + token: passwd, + account: account.clone(), + then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account }, + }] + } Some((authcid, passwd)) => match self.scram_verifier(&authcid) { Some((account, verifier)) => vec![NetAction::DeferAuthenticate { verifier, password: passwd, then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account }, }], - None => mk("D", vec!["F".to_string()]), + None => { + let mut out = mk("D", vec!["F".to_string()]); + out.extend(self.feed("AUTH", format!("\x0304✗\x03 {} — SASL PLAIN failed for \x02{authcid}\x02 (unknown account)", self.who(&client)))); + out + } }, None => mk("D", vec!["F".to_string()]), } diff --git a/src/keycard.rs b/src/keycard.rs new file mode 100644 index 0000000..cf8a0ae --- /dev/null +++ b/src/keycard.rs @@ -0,0 +1,27 @@ +//! Redeeming website login keycards. A member already authenticated on the +//! website (a Django session) connects with a single-use `kc_…` token instead of +//! replaying their password. It isn't the account password, so it can't be +//! checked against a SCRAM verifier — we hand it to Django's localhost +//! login-token endpoint, which redeems the keycard (exactly once) and confirms +//! the bound account. +//! +//! Runs on a blocking thread (see link.rs): a synchronous localhost round-trip, +//! never on the reactor or under the engine lock. +use crate::config::Keycard; + +/// Redeem `token` for `account` against the configured endpoint. Returns true +/// only on a confirmed success (HTTP 2xx). A missing config, transport error, or +/// any non-2xx (bad / expired / already-used / account-mismatched keycard) is a +/// clean `false` — the caller then fails the SASL exchange. +pub fn redeem(cfg: Option<&Keycard>, account: &str, token: &str) -> bool { + let Some(cfg) = cfg else { return false }; + let agent = ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(3)) + .timeout_read(std::time::Duration::from_secs(4)) + .build(); + agent + .post(&cfg.url) + .set("X-API-Key", &cfg.api_key) + .send_form(&[("username", account), ("password", token)]) + .is_ok() +} diff --git a/src/link.rs b/src/link.rs index afdee7a..16e07dc 100644 --- a/src/link.rs +++ b/src/link.rs @@ -71,7 +71,7 @@ fn redact(line: &str) -> Cow<'_, str> { // One uplink session: connect, handshake + burst, then translate lines forever. // The engine is shared with the gossip layer, so it is locked per operation and // never held across the registration key-stretching await. -pub async fn run(mut proto: Box, engine: Arc>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver, email: Option) -> Result<()> { +pub async fn run(mut proto: Box, engine: Arc>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver, email: Option, keycard: Option) -> Result<()> { let stream = TcpStream::connect(addr).await?; let (read, mut write) = stream.into_split(); let mut lines = BufReader::new(read).lines(); @@ -131,6 +131,18 @@ pub async fn run(mut proto: Box, engine: Arc>, addr: .await?; engine.lock().await.complete_authenticate(ok, then) } + NetAction::DeferKeycard { token, account, then } => { + // Redeem a website login keycard off the reactor (a localhost + // HTTP round-trip), then finish the login under the lock — the + // completion is identical to a password auth once we know the + // outcome. + let kc = keycard.clone(); + let ok = tokio::task::spawn_blocking(move || { + crate::keycard::redeem(kc.as_ref(), &account, &token) + }) + .await?; + engine.lock().await.complete_authenticate(ok, then) + } action => vec![action], }; for act in outs { diff --git a/src/main.rs b/src/main.rs index ec5fb7e..5e68a86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod engine; mod gossip; mod grpc; mod jsonrpc; +mod keycard; mod link; mod migrate; mod proto; @@ -262,7 +263,7 @@ async fn main() -> Result<()> { // just lets systemd stop us without waiting out the kill timeout. let shutdown_engine = engine.clone(); tokio::select! { - res = link::run(proto, engine, &addr, irc_rx, cfg.email.clone()) => res, + res = link::run(proto, engine, &addr, irc_rx, cfg.email.clone(), cfg.keycard.clone()) => res, _ = shutdown_signal() => { // Flush stat counters so a clean stop/restart keeps StatServ history. shutdown_engine.lock().await.persist_stats();