From f1084c1056c09c6e6b7210567214e13604450d4e Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 14 Oct 2026 20:21:11 +0300 Subject: [PATCH] feat: answer unknown senders, gated on proving they hold their address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process now splits by whether the sender owns a feed. The owner path runs first, so a rotated owner is never mistaken for a registration, and behaves exactly as before for ordinary mail. The stranger path is the new one, and every branch of it is gated. No signed Reply-To means no address to verify or answer, so it stays a silent drop. The blocked check runs before anything touches the network, so Twt-Block cannot be sidestepped by re-registering. Silent mode stays silent, which is how every version before this behaved — except for an admin, who would otherwise have no way to turn registration back on. Then the gate the whole change rests on: before any outbound mail, the claimed address is resolved on its own server and the resolved key must equal the key that signed the request. Without it a signed Reply-To naming someone else's address would be enough to take their feed, and replying to the claimed smol:// URI would pin the sender's key as verified under that address — a permanent denial of service against whoever really owns it. Every reply is queued against the short form for the same reason; nothing hands a self-certifying URI to send. Registration attempts are counted before the dial rather than after, since one that fails its key check costs the same connection. A per-cycle dial budget bounds how long the state lock is held, and a mail over budget keeps its row instead of being dropped. Admin commands are recognised only from an admin address, and only Admit, Block and Registration count as admin commands — an admin's Twt-Help means Twt-Help. A non-admin sending one gets the ordinary answer and is never told admin commands exist. Rig still passes, so the owner path is unchanged in practice as well as in intent. --- daemon/locales/en-US/main.ftl | 76 +++++- daemon/src/i18n.rs | 11 + daemon/src/mailloop.rs | 458 ++++++++++++++++++++++++++++++++-- 3 files changed, 519 insertions(+), 26 deletions(-) diff --git a/daemon/locales/en-US/main.ftl b/daemon/locales/en-US/main.ftl index fc6d245..0a78225 100644 --- a/daemon/locales/en-US/main.ftl +++ b/daemon/locales/en-US/main.ftl @@ -33,8 +33,8 @@ welcome-body = Your smolboid feed is live: { $url } Post by sending mail to this address with the twt as the body. Manage it - with Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter fields - (PLAN.md). + with the Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter + fields, and send Twt-Help to get this list again. ## Rejection reasons — one per `reason::Reason` variant, rendered into the ## reply subject and body above. Each is a sentence fragment completing @@ -80,3 +80,75 @@ reason-one-command-per-mail = reason-bad-address = `{ $got }` is not a smolmail address reason-registration-mode-unknown = unknown registration mode `{ $got }`, want open, closed or silent + +## First contact — an unknown sender who may register + +info-subject = twtxt: how to get a feed +info-body = + You can publish a twtxt feed here by sending mail. + + Two steps. First accept this address in your client, so our replies reach + your inbox instead of your requests tier: + + fumi accept { $mailbox } + + Then ask for a feed, optionally naming the nick you want: + + fumi send { $mailbox } --header "Twt-Register: yournick" + + We will reply with your feed's address and the commands for managing it. + Nothing is published until you ask for a feed, and this mail is the only + one you get until you do. + +## First contact while registration is closed + +info-closed-subject = twtxt: registration is closed +info-closed-body = + Thanks for writing. This instance is not taking new feeds at the moment, + so there is nothing for you to do here yet. + + Nothing was published and nothing was kept. + +## Twt-Help — the command reference + +help-subject = twtxt: your commands +help-body = + Your feed: { $url } + + Post by sending mail to this address with the twt as the body. The body is + published byte for byte, at most 160 characters. + + Management fields, one per mail: + + Twt-Nick: yournick set the displayed nick + Twt-Delete: remove one post or repost + Twt-Repost: <#id> publish a repost + Twt-Prune: fifo | off what a full feed does at its size cap + Twt-Help: send this list again + +help-admin-section = + + Admin, from your address: + + Twt-Admit: user@host give someone a feed + Twt-Block: user@host withdraw someone's token + Twt-Registration: open | closed | silent + +## Admin notifications + +admin-notify-subject = twtxt: new feed { $slug } +admin-notify-body = + A feed was created. + + Address: { $address } + Feed: { $url } + Fingerprint: { $fingerprint } + + Verify that fingerprint out of band if you do not already know this + author. Twt-Block withdraws their token. + +admin-ack-subject = twtxt: { $what } +admin-ack-body = + { $what } + + Sent because your address is listed as an admin of this instance. diff --git a/daemon/src/i18n.rs b/daemon/src/i18n.rs index 4c2795b..79c6947 100644 --- a/daemon/src/i18n.rs +++ b/daemon/src/i18n.rs @@ -97,6 +97,17 @@ mod tests { "cap-warning-body", "welcome-subject", "welcome-body", + "info-subject", + "info-body", + "info-closed-subject", + "info-closed-body", + "help-subject", + "help-body", + "help-admin-section", + "admin-notify-subject", + "admin-notify-body", + "admin-ack-subject", + "admin-ack-body", ]; #[test] diff --git a/daemon/src/mailloop.rs b/daemon/src/mailloop.rs index 90e5d44..b8b63db 100644 --- a/daemon/src/mailloop.rs +++ b/daemon/src/mailloop.rs @@ -11,13 +11,45 @@ use smolboid_core::feed::{Feed, MailLine, Record}; use smolboid_core::envelope; use crate::config::Config; -use crate::dispatch::{self, Action, Command, Incoming}; +use crate::dispatch::{self, Action, Command, Incoming, Request}; use crate::feedstore::FeedStore; use crate::reason::Reason; -use crate::state::{payload_key, reply_allowed, FeedState, PruneMode, State}; +use crate::register; +use crate::state::{ + global_allowed, payload_key, reply_allowed, FeedState, Global, Mode, PruneMode, State, +}; use std::sync::{Mutex, MutexGuard}; +/// How many mails per cycle may make an outbound connection. +/// +/// `poll` holds the state lock across each mail and a dial can block for +/// `timeout` seconds, so an unbounded batch of registrations would stall mail +/// processing for minutes. Anything over the budget is left in the store for +/// the next cycle rather than dropped. +const DIALS_PER_POLL: usize = 4; + +/// What became of one mail. +#[derive(Debug, PartialEq, Eq)] +enum Outcome { + /// The feed or state changed. + Applied, + /// Handled, with nothing to change — including every silent drop. + Dropped, + /// Needed a dial the cycle could not afford. Left in the store. + Deferred, +} + +/// Whether a request is one only an admin may run. Register and Help are not: +/// an admin sending either means it literally, and routing them through the +/// admin branch would answer with silence. +fn is_admin_command(request: &Request) -> bool { + matches!( + request, + Request::Admit(_) | Request::Block(_) | Request::Registration(_) + ) +} + /// Everything needed to publish one record, before retention gets a say. pub struct PendingRecord { pub timestamp: String, @@ -197,22 +229,30 @@ impl Daemon { let mut dropped = 0; let mut replies: Vec<(String, String, String)> = Vec::new(); // (address, subject, body) + let mut dials = 0; + let mut deferred = 0; for stored in &mail { - let outcome = self.process(stored.id, &stored.envelope, &mut replies); + let outcome = self.process(stored.id, &stored.envelope, &mut replies, &mut dials); + // A deferred mail keeps its row, so the next cycle sees it again. + if outcome.as_ref().is_ok_and(|o| *o == Outcome::Deferred) { + deferred += 1; + continue; + } let _ = self.store.delete_local("inbox", &stored.id); match outcome { - Ok(true) => applied += 1, - Ok(false) => dropped += 1, + Ok(Outcome::Applied) => applied += 1, + Ok(_) => dropped += 1, Err(e) => eprintln!("mail: {e}"), } } let failed = self.send_replies(replies); Ok(format!( - "{applied} applied, {dropped} dropped{}{}", + "{applied} applied, {dropped} dropped{}{}{}", match drained { Some(n) => format!(", {n} backlog discarded"), None => String::new(), }, + if deferred > 0 { format!(", {deferred} deferred") } else { String::new() }, if failed > 0 { format!(", {failed} replies undeliverable") } else { String::new() } )) } @@ -248,14 +288,19 @@ impl Daemon { Ok(Some(requests.len())) } - /// Processes one stored envelope end to end. Ok(true) when the feed or - /// state changed. + /// Processes one stored envelope end to end. + /// + /// Ordering matters twice over. The owner path runs first, so a mail from + /// a rotated owner is never mistaken for a registration. And on the + /// no-feed path the blocked check runs before anything touches the + /// network, so Twt-Block cannot be worked around by re-registering. fn process( &self, id: [u8; 32], envelope: &[u8], replies: &mut Vec<(String, String, String)>, - ) -> Result { + dials: &mut usize, + ) -> Result { let opened = envelope::open(self.account.keys(), envelope, now()) .map_err(|e| format!("open {}: {e}", &fumi_core::crypto::b32(&id)[..8]))?; let incoming = Incoming { @@ -267,23 +312,388 @@ impl Daemon { }; let mut state = self.state.lock().unwrap(); - // Admission: a feed must exist and its owner must still be accepted. - // Requests-tier or blocked mail has neither and is dropped without a - // reply (PLAN.md, Admission and Error replies) — main-tier inbox rows - // are all `mail("inbox")` ever returns. - let index = match state.feed_of(&opened.sender) { - Some(index) => index, - None => match self.follow_rotation(&incoming, &mut state)? { - Some(index) => index, - None => return Ok(false), - }, + let owner = match state.feed_of(&opened.sender) { + Some(index) => Some(index), + None => self.follow_rotation(&incoming, &mut state)?, }; - let feed_state = state.feeds[index].clone(); - if !self.accepted(&feed_state.address)? { - return Ok(false); + + match owner { + Some(index) => self.owner_mail(&incoming, index, &mut state, replies, dials), + None => self.stranger_mail(&incoming, &mut state, replies, dials), + } + } + + /// A mail from someone who already has a feed. + fn owner_mail( + &self, + incoming: &Incoming, + index: usize, + state: &mut MutexGuard<'_, State>, + replies: &mut Vec<(String, String, String)>, + dials: &mut usize, + ) -> Result { + let address = state.feeds[index].address.clone(); + // Blocked: their token is withdrawn, so nothing they send applies and + // they get no reply either. + if !self.accepted(&address)? { + return Ok(Outcome::Dropped); + } + match dispatch::request(incoming) { + // An owner's admin command needs no resolve: the feed's address + // was verified when it was created and only a signed rotation + // chain can move it, so the key on file is enough. + Ok(Some(ref request)) if self.is_admin(&address) && is_admin_command(request) => { + self.run_admin(request, &address, state, replies, dials) + } + // Help, or a request only an admin may send — either way the + // reference is the useful answer, and it quietly omits the admin + // section for a non-admin. + Ok(Some(_)) => { + self.queue_help(state, incoming, &address, index, replies); + Ok(Outcome::Dropped) + } + Ok(None) => self + .apply(incoming, index, state, replies) + .map(|changed| if changed { Outcome::Applied } else { Outcome::Dropped }), + Err(reason) => { + self.queue_reply(state, incoming, &address, &reason, replies); + Ok(Outcome::Dropped) + } + } + } + + /// A mail from someone with no feed. Everything here is gated on proving + /// they hold the address they claim. + fn stranger_mail( + &self, + incoming: &Incoming, + state: &mut MutexGuard<'_, State>, + replies: &mut Vec<(String, String, String)>, + dials: &mut usize, + ) -> Result { + // No signed Reply-To, no address to answer or to register. Dropped in + // silence, as before: without one there is nothing to verify and + // replying would make this daemon a mail reflector for anyone holding + // a freshly minted keypair. + let Some(claim) = dispatch::claimed(incoming) else { + return Ok(Outcome::Dropped); + }; + let address = claim.short(); + + // Before any network call, so a blocked author cannot spend our + // budget by re-registering. + if self.store.accepted_state(&address).unwrap_or(None) == Some(false) { + return Ok(Outcome::Dropped); } - self.apply(&incoming, index, &mut state, replies) + let request = dispatch::request(incoming); + let is_admin_address = self.is_admin(&address); + let mode = state.registration(self.cfg.registration); + + // Silent is the pre-self-service behaviour, and it must stay silent + // even for a well-formed request — except for an admin, who would + // otherwise have no way to turn registration back on. + if mode == Mode::Silent && !is_admin_address { + return Ok(Outcome::Dropped); + } + if !self.host_allowed(&claim) { + return Ok(Outcome::Dropped); + } + if *dials >= DIALS_PER_POLL { + return Ok(Outcome::Deferred); + } + + match request { + // An admin with no feed still has to prove they hold the address, + // because a signed Reply-To naming an admin proves nothing: anyone + // can mint a key and sign a URI carrying someone else's address. + Ok(Some(ref request)) if is_admin_address && is_admin_command(request) => { + *dials += 1; + if !self.holds_address(&claim, &incoming.sender)? { + return Ok(Outcome::Dropped); + } + self.run_admin(request, &address, state, replies, dials) + } + Ok(Some(Request::Register { nick })) => { + if mode == Mode::Closed { + self.queue_closed(state, incoming, &address, replies); + return Ok(Outcome::Dropped); + } + // Counted before the dial, not after: a registration that + // fails its key check costs the same outbound connection as + // one that succeeds. + if !global_allowed( + state, + Global::Attempted, + self.cfg.registrations_per_hour, + now(), + ) { + return Ok(Outcome::Dropped); + } + *dials += 1; + self.register(&claim, nick.as_deref(), incoming, state, replies) + } + // Help, an admin command from a non-admin, no request at all, or a + // malformed one: all the same answer, which is how to get a feed. + // A non-admin is never told that admin commands exist. + _ => { + if mode == Mode::Closed { + self.queue_closed(state, incoming, &address, replies); + return Ok(Outcome::Dropped); + } + if !global_allowed(state, Global::Informed, self.cfg.info_replies_per_hour, now()) { + return Ok(Outcome::Dropped); + } + *dials += 1; + // Resolving first is what makes the reply safe. Sending to the + // claimed smol:// URI would pin the sender's key as verified + // under that address — a permanent denial of service against + // whoever really owns it — and sending to the short form + // without this check would mail the real owner on a stranger's + // behalf. + if !self.holds_address(&claim, &incoming.sender)? { + return Ok(Outcome::Dropped); + } + self.queue_info(state, incoming, &address, replies); + Ok(Outcome::Dropped) + } + } + } + + /// Whether `claim` resolves, on its own server, to the key that signed the + /// mail. This is the check that turns "someone claims this address" into + /// "the address holder wrote to us". + /// + /// A resolve failure is not an error worth surfacing: the address may + /// simply not exist, which is the answer. + fn holds_address(&self, claim: &Address, sender: &[u8; 32]) -> Result { + let mut session = match client::connect(&self.store, claim, false, self.cfg.timeout) { + Ok(session) => session, + Err(e) => { + eprintln!("claim {}: {e}", claim.short()); + return Ok(false); + } + }; + let resolved = client::trust_key(&self.store, session.transport(), claim); + session.close(); + match resolved { + Ok((key, _)) => Ok(&key == sender), + Err(e) => { + eprintln!("claim {}: {e}", claim.short()); + Ok(false) + } + } + } + + fn is_admin(&self, address: &str) -> bool { + self.cfg.admins.iter().any(|a| a == address) + } + + /// An empty allowlist means any host, and a non-empty one is the only way + /// to stop the daemon dialling a host a stranger chose. + fn host_allowed(&self, claim: &Address) -> bool { + self.cfg.register_hosts.is_empty() + || self.cfg.register_hosts.iter().any(|h| h == &claim.host) + } + + /// Creates the feed and hands over the token. `expect` is the signing key, + /// so admission refuses an address that resolves to anyone else. + fn register( + &self, + claim: &Address, + nick: Option<&str>, + incoming: &Incoming, + state: &mut MutexGuard<'_, State>, + replies: &mut Vec<(String, String, String)>, + ) -> Result { + let admitted = match register::admit( + register::Context { + cfg: &self.cfg, + store: &self.store, + account: &self.account, + feeds: &self.feeds, + }, + state, + claim, + nick, + Some(&incoming.sender), + ) { + Ok(admitted) => admitted, + Err(e) => { + // Includes the key-mismatch refusal, which must stay quiet: + // the sender is not the address holder, so there is nobody + // here to answer. + eprintln!("register {}: {e}", claim.short()); + return Ok(Outcome::Dropped); + } + }; + + let url = register::feed_url(&self.cfg, &admitted.slug); + replies.push(( + claim.short(), + crate::i18n::t("welcome-subject"), + crate::i18n::t1("welcome-body", "url", url.clone()), + )); + if !admitted.synced { + eprintln!( + "register {}: the accept token did not reach the server", + claim.short() + ); + } + if self.cfg.admin_notify { + self.notify_admins(&admitted, claim, &url, replies); + } + Ok(Outcome::Applied) + } + + fn notify_admins( + &self, + admitted: ®ister::Admitted, + claim: &Address, + url: &str, + replies: &mut Vec<(String, String, String)>, + ) { + let mut args = fluent_bundle::FluentArgs::new(); + args.set("address", claim.short()); + args.set("url", url.to_owned()); + args.set("fingerprint", fumi_core::crypto::fingerprint(&admitted.key)); + let body = crate::i18n::t_args("admin-notify-body", Some(&args)); + let subject = crate::i18n::t1("admin-notify-subject", "slug", admitted.slug.clone()); + for admin in &self.cfg.admins { + replies.push((admin.clone(), subject.clone(), body.clone())); + } + } + + /// Runs an admin command. The caller has already established that the + /// sender holds an admin address. + fn run_admin( + &self, + request: &Request, + address: &str, + state: &mut MutexGuard<'_, State>, + replies: &mut Vec<(String, String, String)>, + dials: &mut usize, + ) -> Result { + let what = match request { + Request::Admit(target) => { + if *dials >= DIALS_PER_POLL { + return Ok(Outcome::Deferred); + } + *dials += 1; + let target_addr = + Address::parse(target).map_err(|e| format!("admit target: {e}"))?; + // expect = None: the target has not written in, so there is no + // signing key to match. The admin vouches, as on the CLI. + match register::admit( + register::Context { + cfg: &self.cfg, + store: &self.store, + account: &self.account, + feeds: &self.feeds, + }, + state, + &target_addr, + None, + None, + ) { + Ok(admitted) => { + let url = register::feed_url(&self.cfg, &admitted.slug); + if admitted.created { + replies.push(( + target_addr.short(), + crate::i18n::t("welcome-subject"), + crate::i18n::t1("welcome-body", "url", url.clone()), + )); + } + format!("admitted {target}: {url}") + } + Err(e) => format!("could not admit {target}: {e}"), + } + } + Request::Block(target) => match self.store.block(target) { + Ok(true) => { + match client::push_tokens(&self.store, &self.account, None, self.cfg.timeout) { + Ok(_) => format!("blocked {target}"), + Err(e) => format!("blocked {target} locally, but the server still holds their token: {e}"), + } + } + Ok(false) => format!("{target} was never accepted"), + Err(e) => format!("could not block {target}: {e}"), + }, + Request::Registration(mode) => { + state.set_registration(*mode); + state + .save(&self.cfg.data.join("state.json")) + .map_err(|e| format!("state: {e}"))?; + format!("registration is now {mode:?}").to_lowercase() + } + // Not an admin command; the caller routes these elsewhere. + Request::Register { .. } | Request::Help => return Ok(Outcome::Dropped), + }; + replies.push(( + address.to_owned(), + crate::i18n::t1("admin-ack-subject", "what", what.clone()), + crate::i18n::t1("admin-ack-body", "what", what), + )); + Ok(Outcome::Applied) + } + + /// The command reference, with the admin section only for an admin. + fn queue_help( + &self, + state: &mut MutexGuard<'_, State>, + incoming: &Incoming, + address: &str, + index: usize, + replies: &mut Vec<(String, String, String)>, + ) { + let key = payload_key(&incoming.sender, incoming.time, &incoming.body); + if !reply_allowed(state, &incoming.sender, &key, now()) { + return; + } + let slug = state.feeds[index].username.clone(); + let mut body = crate::i18n::t1("help-body", "url", register::feed_url(&self.cfg, &slug)); + if self.is_admin(address) { + body.push_str(&crate::i18n::t("help-admin-section")); + } + replies.push((address.to_owned(), crate::i18n::t("help-subject"), body)); + } + + fn queue_info( + &self, + state: &mut MutexGuard<'_, State>, + incoming: &Incoming, + address: &str, + replies: &mut Vec<(String, String, String)>, + ) { + let key = payload_key(&incoming.sender, incoming.time, &incoming.body); + if !reply_allowed(state, &incoming.sender, &key, now()) { + return; + } + let mailbox = format!("{}@{}", self.cfg.mailbox, self.cfg.smolmail_host); + replies.push(( + address.to_owned(), + crate::i18n::t("info-subject"), + crate::i18n::t1("info-body", "mailbox", mailbox), + )); + } + + fn queue_closed( + &self, + state: &mut MutexGuard<'_, State>, + incoming: &Incoming, + address: &str, + replies: &mut Vec<(String, String, String)>, + ) { + let key = payload_key(&incoming.sender, incoming.time, &incoming.body); + if !reply_allowed(state, &incoming.sender, &key, now()) { + return; + } + replies.push(( + address.to_owned(), + crate::i18n::t("info-closed-subject"), + crate::i18n::t("info-closed-body"), + )); } fn accepted(&self, address: &str) -> Result { @@ -441,7 +851,7 @@ impl Daemon { replies: &mut Vec<(String, String, String)>, ) { let key = payload_key(&incoming.sender, incoming.time, &incoming.body); - if !reply_allowed(&mut **state, &incoming.sender, &key, now()) { + if !reply_allowed(state, &incoming.sender, &key, now()) { return; } let text = std::str::from_utf8(&incoming.body)