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

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