keycard: redeem website login keycards over SASL

This commit is contained in:
Jean Chevronnet 2026-07-16 22:27:59 +00:00
parent 38d12cb0bb
commit 9bdda721f3
9 changed files with 359 additions and 4 deletions

278
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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<String> },

View file

@ -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

View file

@ -41,6 +41,20 @@ pub struct Config {
// and pushes accounts in; IRC can only authenticate.
#[serde(default)]
pub auth: Option<Auth>,
// Website single-use keycards (passwordless web login). Absent = keycard
// credentials (`kc_…` over SASL) are not honoured.
#[serde(default)]
pub keycard: Option<Keycard>,
}
// 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.

View file

@ -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()]),
}

27
src/keycard.rs Normal file
View file

@ -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()
}

View file

@ -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<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>, email: Option<crate::config::Email>) -> Result<()> {
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>, email: Option<crate::config::Email>, keycard: Option<crate::config::Keycard>) -> 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<dyn Protocol>, engine: Arc<Mutex<Engine>>, 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 {

View file

@ -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();