From 03e54fea49b50a763186819fb92318abe36fd09c Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 14 Oct 2026 19:37:17 +0300 Subject: [PATCH] feat: parse mailbox requests and factor out the address claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch::request recognises Twt-Register, Twt-Help, Twt-Admit, Twt-Block and Twt-Registration, validating values but deciding nothing: whether the sender may do what they asked is the caller's business, as with the rest of this module. One command per mail. Two requests have no sensible order, and a request mixed with a feed command would make the reply ambiguous about what took effect. Twt-Register takes an optional nick, validated against the nick rule rather than the path rule, since the value lands in feed metadata. dispatch::claimed is follow_rotation's Reply-To extraction, lifted out and documented for what it actually proves: the sender signed this URI. It does not prove they hold the mailbox it names. follow_rotation is safe on that alone only because it additionally demands a feed already bound to the address and a signed chain from the key on file; a registration path has neither and must resolve the address itself. Twt-Help ignores any value rather than refusing one — failing a request for help on a typo would be perverse. Also narrows i18n's id list back to the ids looked up by name: generating it from the catalogue made that test assert the catalogue against itself. The reason-* ids are covered by walking the enum instead, which cannot drift. --- daemon/locales/en-US/main.ftl | 9 ++ daemon/src/dispatch.rs | 222 +++++++++++++++++++++++++++++++++- daemon/src/i18n.rs | 6 +- daemon/src/mailloop.rs | 18 +-- daemon/src/reason.rs | 17 ++- 5 files changed, 254 insertions(+), 18 deletions(-) diff --git a/daemon/locales/en-US/main.ftl b/daemon/locales/en-US/main.ftl index e0505ce..fc6d245 100644 --- a/daemon/locales/en-US/main.ftl +++ b/daemon/locales/en-US/main.ftl @@ -71,3 +71,12 @@ reason-reply-to-changed = your Reply-To does not match the feed's bound author { $author }; send from that address or mail the operator reason-no-record-at = no record at { $timestamp } + +## Mailbox requests — register, help and the admin commands + +reason-one-command-per-mail = + one command per mail: send Twt-Register, Twt-Help, Twt-Admit, Twt-Block or + Twt-Registration on its own, and feed commands in a separate mail +reason-bad-address = `{ $got }` is not a smolmail address +reason-registration-mode-unknown = + unknown registration mode `{ $got }`, want open, closed or silent diff --git a/daemon/src/dispatch.rs b/daemon/src/dispatch.rs index 81efae7..8cd685a 100644 --- a/daemon/src/dispatch.rs +++ b/daemon/src/dispatch.rs @@ -7,8 +7,10 @@ use smolboid_core::rules::validate_text; use smolboid_core::time; use smolboid_core::{repost_body, reply_to_body}; +use fumi_core::address::Address; + use crate::reason::Reason; -use crate::state::PruneMode; +use crate::state::{Mode, PruneMode}; /// One fetched mail, opened and verified by the caller. pub struct Incoming { @@ -47,6 +49,26 @@ pub enum Command { Prune(PruneMode), } +/// A request that is about the mailbox rather than about a feed's contents. +/// These are answered before `dispatch` is consulted, because several of them +/// come from senders who have no feed at all. +// Consumed by the mail loop's request handling; until then only the tests +// construct these. +#[allow(dead_code)] +#[derive(Debug, PartialEq, Eq)] +pub enum Request { + /// Create a feed for the sender, optionally with a starting nick. + Register { nick: Option }, + /// Re-send the command reference. + Help, + /// Admin: issue a token and create a feed for someone else. + Admit(String), + /// Admin: withdraw someone's token. + Block(String), + /// Admin: open or close self-service registration. + Registration(Mode), +} + /// The `Twt-` frontmatter fields a command mail may carry; unknown keys are /// preserved upstream and never alter behaviour (SPEC.md §5.5). const F_NICK: &str = "twt-nick"; @@ -54,6 +76,106 @@ const F_DELETE: &str = "twt-delete"; const F_REPOST: &str = "twt-repost"; const F_PRUNE: &str = "twt-prune"; const F_REPLY_TO: &str = "reply-to"; +#[allow(dead_code)] +const F_REGISTER: &str = "twt-register"; +#[allow(dead_code)] +const F_HELP: &str = "twt-help"; +#[allow(dead_code)] +const F_ADMIT: &str = "twt-admit"; +#[allow(dead_code)] +const F_BLOCK: &str = "twt-block"; +#[allow(dead_code)] +const F_REGISTRATION: &str = "twt-registration"; + +/// The fields `dispatch` handles, i.e. everything about a feed's contents. +const FEED_FIELDS: [&str; 4] = [F_NICK, F_DELETE, F_PRUNE, F_REPOST]; +/// The fields `request` handles, i.e. everything about the mailbox. +#[allow(dead_code)] +const REQUEST_FIELDS: [&str; 5] = [F_REGISTER, F_HELP, F_ADMIT, F_BLOCK, F_REGISTRATION]; + +/// The address a mail claims to be from: the `smol://` Reply-To it signed, +/// accepted only when the key it carries is the key that signed the payload. +/// +/// That check proves the signer signed this URI — nothing more. It does *not* +/// prove they hold the mailbox it names, so a caller acting on an unknown +/// sender must still resolve the address against its own server. The existing +/// rotation path gets away without that only because it additionally requires +/// a feed already bound to the address and a signed chain from the key on +/// file. +pub fn claimed(incoming: &Incoming) -> Option
{ + let text = std::str::from_utf8(&incoming.body).ok()?; + let (fields, _) = fumi_core::message::parse_frontmatter(text); + let addr = Address::parse(fields.get(F_REPLY_TO)?).ok()?; + (addr.identity? == incoming.sender).then_some(addr) +} + +/// A mailbox request, if the mail carries one. `Ok(None)` means it is an +/// ordinary post or feed command and belongs to `dispatch`. +/// +/// Pure, like `dispatch`: whether the sender may *do* what they asked — admin +/// rights, registration mode, rate budgets — is the caller's decision. +#[allow(dead_code)] +pub fn request(incoming: &Incoming) -> Result, Reason> { + let Ok(text) = std::str::from_utf8(&incoming.body) else { + // Not UTF-8 is a rejection, but `dispatch` already says so and this + // layer must not answer for mail that was never a request. + return Ok(None); + }; + if !text.starts_with("---\n") { + return Ok(None); + } + let (fields, _) = fumi_core::message::parse_frontmatter(text); + let present: Vec<&str> = REQUEST_FIELDS + .iter() + .copied() + .filter(|f| fields.contains_key(*f)) + .collect(); + let [field] = present[..] else { + if present.is_empty() { + return Ok(None); + } + // Two requests in one mail have no sensible order, and the reply could + // only report one of them. + return Err(Reason::OneCommandPerMail); + }; + // A request mail is about the mailbox; a feed command is about its + // contents. Mixing them would make the reply ambiguous about what did and + // did not take effect. + if FEED_FIELDS.iter().any(|f| fields.contains_key(*f)) { + return Err(Reason::OneCommandPerMail); + } + let value = fields.get(field).map(String::as_str).unwrap_or_default(); + let request = match field { + F_REGISTER if value.is_empty() => Request::Register { nick: None }, + F_REGISTER if crate::feedstore::valid_username(value) => Request::Register { + nick: Some(value.to_owned()), + }, + F_REGISTER => return Err(Reason::NickInvalid), + // Any value is ignored rather than refused: there is nothing an author + // could usefully put here, and failing their cry for help on a typo + // would be perverse. + F_HELP => Request::Help, + F_ADMIT | F_BLOCK => { + let addr = Address::parse(value) + .map_err(|_| Reason::BadAddress { got: value.to_owned() })?; + if field == F_ADMIT { + Request::Admit(addr.short()) + } else { + Request::Block(addr.short()) + } + } + F_REGISTRATION => Request::Registration(match value { + "open" => Mode::Open, + "closed" => Mode::Closed, + "silent" => Mode::Silent, + other => { + return Err(Reason::RegistrationModeUnknown { got: other.to_owned() }) + } + }), + _ => unreachable!("field came from REQUEST_FIELDS"), + }; + Ok(Some(request)) +} /// Fails closed: a block with unreconstructible fields is never published. /// @@ -309,6 +431,104 @@ mod tests { ); } + /// The claim check is signature-scoped on purpose: it says the sender + /// signed this URI, not that they hold the mailbox it names. + #[test] + fn a_claim_needs_the_senders_own_key_in_the_uri() { + // `incoming` signs as [1; 32]; base32 of that key is what must appear. + let key = fumi_core::crypto::b32(&[1u8; 32]); + let mine = format!("---\nReply-To: smol://alice@other.org/{key}\n---\n"); + let addr = claimed(&incoming(&mine)).expect("own key should be claimable"); + assert_eq!(addr.short(), "alice@other.org"); + + // Someone else's key in the URI: the signer did not sign for it. + let theirs = format!( + "---\nReply-To: smol://alice@other.org/{}\n---\n", + fumi_core::crypto::b32(&[2u8; 32]) + ); + assert!(claimed(&incoming(&theirs)).is_none()); + + // A short-form Reply-To carries no key, so it proves nothing. + assert!(claimed(&incoming("---\nReply-To: alice@other.org\n---\n")).is_none()); + assert!(claimed(&incoming("hello")).is_none()); + } + + #[test] + fn mailbox_requests_are_recognised_and_validated() { + let req = |body: &str| request(&incoming(body)); + + // Ordinary mail is not a request. + assert_eq!(req("hello").unwrap(), None); + assert_eq!(req("---\nTwt-Nick: toby\n---\n").unwrap(), None); + + assert_eq!( + req("---\nTwt-Register:\n---\n").unwrap(), + Some(Request::Register { nick: None }) + ); + assert_eq!( + req("---\nTwt-Register: toby\n---\n").unwrap(), + Some(Request::Register { nick: Some("toby".into()) }) + ); + // The nick rule, not the path rule: a slug would be wrong in metadata. + assert_eq!(req("---\nTwt-Register: Bad Nick\n---\n").unwrap_err(), Reason::NickInvalid); + assert_eq!( + req("---\nTwt-Register: a@b.org\n---\n").unwrap_err(), + Reason::NickInvalid + ); + + assert_eq!(req("---\nTwt-Help:\n---\n").unwrap(), Some(Request::Help)); + // A value is ignored rather than refused. + assert_eq!(req("---\nTwt-Help: please\n---\n").unwrap(), Some(Request::Help)); + + assert_eq!( + req("---\nTwt-Admit: alice@other.org\n---\n").unwrap(), + Some(Request::Admit("alice@other.org".into())) + ); + // Normalised through short(), so the default port collapses. + assert_eq!( + req("---\nTwt-Block: alice@other.org:1961\n---\n").unwrap(), + Some(Request::Block("alice@other.org".into())) + ); + assert_eq!( + req("---\nTwt-Admit: nonsense\n---\n").unwrap_err(), + Reason::BadAddress { got: "nonsense".into() } + ); + + for (value, mode) in [("open", Mode::Open), ("closed", Mode::Closed), ("silent", Mode::Silent)] { + assert_eq!( + req(&format!("---\nTwt-Registration: {value}\n---\n")).unwrap(), + Some(Request::Registration(mode)) + ); + } + assert_eq!( + req("---\nTwt-Registration: maybe\n---\n").unwrap_err(), + Reason::RegistrationModeUnknown { got: "maybe".into() } + ); + } + + #[test] + fn one_command_per_mail() { + let req = |body: &str| request(&incoming(body)); + // Two requests have no sensible order. + assert_eq!( + req("---\nTwt-Register:\nTwt-Help:\n---\n").unwrap_err(), + Reason::OneCommandPerMail + ); + // A request mixed with a feed command would make the reply ambiguous. + for body in [ + "---\nTwt-Register:\nTwt-Nick: toby\n---\n", + "---\nTwt-Help:\nTwt-Prune: fifo\n---\n", + "---\nTwt-Admit: a@b.org\nTwt-Delete: 2026-10-01T12:47:00Z\n---\n", + ] { + assert_eq!(req(body).unwrap_err(), Reason::OneCommandPerMail, "{body:?}"); + } + // A Reply-To alongside a request is normal, not a conflict. + assert_eq!( + req("---\nReply-To: smol://a@b.org/k\nTwt-Help:\n---\n").unwrap(), + Some(Request::Help) + ); + } + #[test] fn odd_mail_shapes_fail_closed_to_ignore() { // Malformed block, empty escape block, only unknown fields with a diff --git a/daemon/src/i18n.rs b/daemon/src/i18n.rs index 8d74595..4c2795b 100644 --- a/daemon/src/i18n.rs +++ b/daemon/src/i18n.rs @@ -86,8 +86,10 @@ mod tests { } } - /// Every id the daemon looks up. A lookup added without a line here still - /// works, but loses its compile-adjacent guard — keep them in step. + /// The ids looked up by name in code. The `reason-*` ids are deliberately + /// absent: `reason::Reason` covers every one of those by walking the enum, + /// which cannot drift the way a hand-kept list can. Listing them here too + /// would only make this test restate the catalogue back to itself. const IDS: &[&str] = &[ "reply-subject", "reply-body", diff --git a/daemon/src/mailloop.rs b/daemon/src/mailloop.rs index 86a72b9..fde3b63 100644 --- a/daemon/src/mailloop.rs +++ b/daemon/src/mailloop.rs @@ -246,22 +246,12 @@ impl Daemon { incoming: &Incoming, state: &mut MutexGuard<'_, State>, ) -> Result, String> { - let Ok(text) = std::str::from_utf8(&incoming.body) else { + let Some(addr) = dispatch::claimed(incoming) else { return Ok(None); }; - let (fields, _) = fumi_core::message::parse_frontmatter(text); - let Some(reply_to) = fields.get("reply-to") else { - return Ok(None); - }; - let Ok(addr) = Address::parse(reply_to) else { - return Ok(None); - }; - let Some(user_key) = addr.identity else { - return Ok(None); - }; - if user_key != incoming.sender { - return Ok(None); - } + // The claim alone proves only that the sender signed this URI. What + // makes it safe here is the next two lines: the address must already + // own a feed, and the chain below must lead from the key on file. let Some(index) = state.feeds.iter().position(|f| f.address == addr.short()) else { return Ok(None); }; diff --git a/daemon/src/reason.rs b/daemon/src/reason.rs index 8cea608..05f4c13 100644 --- a/daemon/src/reason.rs +++ b/daemon/src/reason.rs @@ -11,6 +11,7 @@ use fluent_bundle::FluentArgs; use smolboid_core::rules::RuleError; +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub enum Reason { BodyNotUtf8, @@ -29,6 +30,12 @@ pub enum Reason { FeedFull, ReplyToChanged { author: String }, NoRecordAt { timestamp: String }, + /// Two mailbox requests in one mail, or one mixed with a feed command. + // Constructed by `dispatch::request`, which the mail loop does not call + // yet; the tests cover them meanwhile. + OneCommandPerMail, + BadAddress { got: String }, + RegistrationModeUnknown { got: String }, } impl From for Reason { @@ -60,6 +67,9 @@ impl Reason { Reason::FeedFull => "reason-feed-full", Reason::ReplyToChanged { .. } => "reason-reply-to-changed", Reason::NoRecordAt { .. } => "reason-no-record-at", + Reason::OneCommandPerMail => "reason-one-command-per-mail", + Reason::BadAddress { .. } => "reason-bad-address", + Reason::RegistrationModeUnknown { .. } => "reason-registration-mode-unknown", } } @@ -67,7 +77,9 @@ impl Reason { pub fn args(&self) -> Option> { let mut args = FluentArgs::new(); match self { - Reason::PruneModeUnknown { got } => args.set("got", got.clone()), + Reason::PruneModeUnknown { got } + | Reason::BadAddress { got } + | Reason::RegistrationModeUnknown { got } => args.set("got", got.clone()), Reason::Collision { timestamp } | Reason::NoRecordAt { timestamp } => { args.set("timestamp", timestamp.clone()) } @@ -112,6 +124,9 @@ mod tests { Reason::FeedFull, Reason::ReplyToChanged { author: "tobias@smol.place".into() }, Reason::NoRecordAt { timestamp: "2026-10-01T12:47:00Z".into() }, + Reason::OneCommandPerMail, + Reason::BadAddress { got: "not an address".into() }, + Reason::RegistrationModeUnknown { got: "sideways".into() }, ]; for reason in all { let text = reason.text();