feat: extract admission into register.rs, with a key-equality gate

The CLI grew the only admission implementation, which a mail-driven one would
have had to duplicate. register::admit is now that path for both callers; it
sends no mail and prints nothing, so the caller decides what the author and
the operator see.

expect is the point of the exercise. A mail-driven registration knows which
key signed the request and must require the address to resolve to that same
key, because a signed Reply-To naming someone else's address proves only that
the sender signed the URI. The CLI passes None and prints the resolved key and
fingerprint instead, since the operator vouches out of band for a target who
has not written in.

slug_of compares the daemon's own registered address, host and port, not
smolmail_host: that setting carries no port while short() does, so two servers
on one host at different ports would both collapse onto bare local slugs and
collide. A test pins that, and another checks every derived slug is a legal
feed path.

Three details that were wrong or silent before:
- synced is reported rather than assumed. push_tokens is inert when the local
  token set may not replace the server's, and the author then stays in the
  requests tier forever; the CLI now says so loudly.
- the welcome mail is sent only when a feed was created, so a re-admit no
  longer mails someone a second time.
- a re-registration may update the nick; the path never moves.

Verified with the rig: admit, post, commands, repost, delete, collision, both
retention modes and an author rotation all pass against a real bunshin.
This commit is contained in:
randogoth 2026-10-14 19:58:31 +03:00
parent 03e54fea49
commit 2d2b42dd6f
2 changed files with 279 additions and 88 deletions

View file

@ -11,6 +11,7 @@ mod http;
mod i18n;
mod mailloop;
mod reason;
mod register;
mod state;
use std::path::{Path, PathBuf};
@ -24,7 +25,7 @@ use fumi_core::client::{self, Pushed};
use fumi_core::crypto::{b32, fingerprint};
use fumi_core::store::Store;
use mailloop::Daemon;
use state::{FeedState, State};
use state::State;
const USAGE: &str = "\
smolboid — twtxt feeds published by smolmail
@ -125,98 +126,66 @@ fn admit(config: ConfigAndArg) -> Result<(), String> {
let (cfg, store, account, mut state, feeds) = load(&config)?;
let addr = Address::parse(&config.arg).map_err(|e| format!("address: {e}"))?;
// Verify the author's key against their own server, then accept them.
let mut session = client::connect(&store, &addr, false, cfg.timeout)
.map_err(|e| format!("connecting to {}: {e}", addr.host))?;
let (key, change) = client::trust_key(&store, session.transport(), &addr)
.map_err(|e| format!("resolving {}: {e}", config.arg))?;
session.close();
store
.accept(&addr.short(), &key)
.map_err(|e| format!("accepting: {e}"))?;
match client::push_tokens(&store, &account, None, cfg.timeout) {
Ok(Pushed::Held(held)) => println!("server now holds {held} accept token(s)"),
Ok(Pushed::NotRegistered) => println!("not registered; tokens travel with the first fetch"),
Err(e) => return Err(format!("pushing tokens: {e}")),
}
println!(
"{}: {} ({:?})",
config.arg,
b32(&key),
change
);
// expect = None: the target has not written in, so there is no signing key
// to match. The operator vouches for them instead, which is why the
// resolved key is printed below for an out-of-band check.
let admitted = register::admit(
register::Context { cfg: &cfg, store: &store, account: &account, feeds: &feeds },
&mut state,
&addr,
None,
None,
)?;
// The username is the feed path; a second owner for one username is a
// collision the operator must see.
let existing = state.feed_by_username(&addr.user);
if let Some(existing) = existing {
if existing.address != addr.short() {
return Err(format!(
"username `{}` already has a feed from {}",
addr.user, existing.address
));
}
println!("{}: {} ({:?})", addr.short(), b32(&admitted.key), admitted.change);
println!("fingerprint: {}", fingerprint(&admitted.key));
if admitted.created {
println!(
"feed created: {} (host key {})",
register::feed_url(&cfg, &admitted.slug),
b32(&account.me().pk())
);
} else {
println!("feed already exists; re-accepted");
}
let feed_state = FeedState {
username: addr.user.clone(),
address: addr.short(),
owner_keys: vec![b32(&key)],
nick: addr.user.clone(),
prune: Default::default(),
warned: false,
day: State::day(0),
posts: 0,
reposts: 0,
};
if existing.is_none() {
feeds.create(
&feed_state.username,
&feed_state.nick,
&feed_state.address,
&cfg.base_url,
&account.me().pk(),
)?;
state.feeds.push(feed_state);
if !admitted.synced {
// Worth shouting about: without the token on the server their mail
// stays in the requests tier and nothing they send ever publishes.
eprintln!(
"smolboid: the accept token did not reach the server; \
this author cannot publish until it does"
);
}
state
.save(&cfg.data.join("state.json"))
.map_err(|e| format!("state: {e}"))?;
println!(
"feed created: {}/twtxt/{}.txt (host key {})",
cfg.base_url,
addr.user,
b32(&account.me().pk())
);
// The welcome mail carries the Accept token (sec 5.8), which is what
// moves the author's posts into the daemon's main tier; without it their
// mail would stay in requests and never publish.
let welcome_subject = i18n::t("welcome-subject");
let sent = client::send(
&store,
&account,
&client::SendDraft {
address: &addr,
text: i18n::t1(
"welcome-body",
"url",
format!("{}/twtxt/{}.txt", cfg.base_url, addr.user),
),
subject: Some(&welcome_subject),
reply_to: None,
headers: &[],
anonymous: false,
no_pad: true,
},
cfg.timeout,
)
.map_err(|e| format!("welcome mail: {e}"))?;
println!(
"welcome mail sent{}; fetch it before your first post",
if sent.token_used { " with the accept token" } else { "" }
);
// The welcome mail carries the Accept token (sec 5.8), which is what moves
// the author's posts into the main tier. Sent from here rather than from
// register::admit, so a re-admit does not mail someone twice.
if admitted.created {
let welcome_subject = i18n::t("welcome-subject");
let sent = client::send(
&store,
&account,
&client::SendDraft {
address: &addr,
text: i18n::t1(
"welcome-body",
"url",
register::feed_url(&cfg, &admitted.slug),
),
subject: Some(&welcome_subject),
reply_to: None,
headers: &[],
anonymous: false,
no_pad: true,
},
cfg.timeout,
)
.map_err(|e| format!("welcome mail: {e}"))?;
println!(
"welcome mail sent{}; fetch it before your first post",
if sent.token_used { " with the accept token" } else { "" }
);
}
Ok(())
}

222
daemon/src/register.rs Normal file
View file

@ -0,0 +1,222 @@
//! Admission: resolve an address, issue it a token, and give it a feed.
//!
//! One code path for both callers. The CLI `admit` subcommand and the mail
//! loop's registration handler differ only in what they do afterwards —
//! printing versus queueing a reply — so neither the printing nor the mail
//! lives here.
//!
//! The security-relevant part is `expect`. A mail-driven registration knows
//! which key signed the request, and it must require the address to resolve
//! to that same key; without it, a signed `Reply-To` naming someone else's
//! address is enough to take their feed. See `dispatch::claimed`.
use fumi_core::account::Account;
use fumi_core::address::Address;
use fumi_core::client::{self, Pushed, TrustChange};
use fumi_core::crypto::b32;
use fumi_core::store::Store;
use crate::config::Config;
use crate::feedstore::FeedStore;
use crate::state::{FeedState, State};
/// The long-lived pieces admission needs, borrowed rather than owned so the
/// daemon can hand over what it already holds.
pub struct Context<'a> {
pub cfg: &'a Config,
pub store: &'a Store,
pub account: &'a Account,
pub feeds: &'a FeedStore,
}
/// What admission did, for the caller to report or act on.
pub struct Admitted {
/// The feed's path segment.
pub slug: String,
/// The key the address resolved to.
pub key: [u8; 32],
pub change: TrustChange,
/// False when the feed already existed and was only re-accepted.
pub created: bool,
/// Whether the token actually reached the server. False means the local
/// token set was not allowed to replace the server's, so the author will
/// stay in the requests tier until an operator sorts it out — a silent
/// no-op if the caller does not surface it.
pub synced: bool,
}
/// The feed's path segment for an address: bare for an author on our own
/// server, `user@host[:port]` for anyone else.
///
/// Compared against the daemon's own registered address, host *and* port,
/// rather than the configured `smolmail_host`: that setting carries no port
/// while `Address::short()` does, so two servers on one host at different
/// ports would otherwise both collapse onto bare local slugs and collide.
///
/// Derived, never author-supplied, which is what makes a collision impossible
/// rather than merely unlikely.
pub fn slug_of(addr: &Address, own: &Address) -> String {
if addr.host == own.host && addr.port == own.port {
addr.user.clone()
} else {
addr.short()
}
}
/// Resolves `addr` on its own server, accepts it, and creates its feed.
///
/// `expect` is the key that signed the request, when there was one. Passing it
/// is what turns "someone claims this address" into "the address holder asked"
/// — the mail path must always pass it, and the CLI passes `None` because the
/// operator vouches out of band for a target who has not written in.
pub fn admit(
ctx: Context<'_>,
state: &mut State,
addr: &Address,
nick: Option<&str>,
expect: Option<&[u8; 32]>,
) -> Result<Admitted, String> {
let own = ctx
.store
.account()
.map_err(|e| format!("own account: {e}"))?
.ok_or("not registered; bootstrap the mailbox first")?;
// trust_key rather than a bare resolve: it is the call that refuses a key
// change which cannot chain from the one on file (SPEC.md sec 7), and that
// refusal is what stops a recycled mailbox name taking over an existing
// feed.
let mut session = client::connect(ctx.store, addr, false, ctx.cfg.timeout)
.map_err(|e| format!("connecting to {}: {e}", addr.host))?;
let resolved = client::trust_key(ctx.store, session.transport(), addr);
session.close();
let (key, change) = resolved.map_err(|e| format!("resolving {}: {e}", addr.short()))?;
if let Some(expect) = expect {
if &key != expect {
// The claimed address resolves to someone else, so whoever asked
// does not hold it. Fails closed and says nothing more: the reply
// goes to a sender who is not the address holder.
return Err(format!(
"{} resolves to {}, not the key that signed the request",
addr.short(),
b32(&key)
));
}
}
ctx.store
.accept(&addr.short(), &key)
.map_err(|e| format!("accepting: {e}"))?;
// An accept whose push fails leaves a safe retry state: the author's mail
// still arrives in the requests tier, so a later attempt runs again.
let synced = match client::push_tokens(ctx.store, ctx.account, None, ctx.cfg.timeout) {
Ok(Pushed::Held(_)) => ctx
.store
.sync_ok()
.map_err(|e| format!("sync state: {e}"))?,
// Nothing was sent; the set rides along with the next fetch.
Ok(Pushed::NotRegistered) => false,
Err(e) => return Err(format!("pushing tokens: {e}")),
};
let slug = slug_of(addr, &own);
// The slug is the feed path, so a second owner for one slug is a conflict
// the caller must see rather than silently overwrite.
let existing = state.feed_by_username(&slug);
if let Some(existing) = existing {
if existing.address != addr.short() {
return Err(format!(
"feed `{}` already belongs to {}",
slug, existing.address
));
}
}
let created = existing.is_none();
if created {
let feed_state = FeedState {
username: slug.clone(),
address: addr.short(),
owner_keys: vec![b32(&key)],
nick: nick.unwrap_or(&addr.user).to_owned(),
prune: Default::default(),
warned: false,
day: State::day(0),
posts: 0,
reposts: 0,
};
ctx.feeds.create(
&feed_state.username,
&feed_state.nick,
&feed_state.address,
&ctx.cfg.base_url,
&ctx.account.me().pk(),
)?;
state.feeds.push(feed_state);
} else if let Some(nick) = nick {
// A re-registration may still set the nick; the path never moves.
if let Some(fs) = state.feeds.iter_mut().find(|f| f.username == slug) {
fs.nick = nick.to_owned();
}
}
state
.save(&ctx.cfg.data.join("state.json"))
.map_err(|e| format!("state: {e}"))?;
Ok(Admitted { slug, key, change, created, synced })
}
/// The feed's public URL, as the welcome and help mails quote it.
pub fn feed_url(cfg: &Config, slug: &str) -> String {
format!("{}/twtxt/{slug}.txt", cfg.base_url)
}
#[cfg(test)]
mod tests {
use super::*;
fn addr(text: &str) -> Address {
Address::parse(text).unwrap()
}
#[test]
fn local_authors_keep_a_bare_slug_and_remote_ones_carry_their_host() {
let own = addr("twtxt@smol.place");
assert_eq!(slug_of(&addr("alice@smol.place"), &own), "alice");
assert_eq!(slug_of(&addr("alice@other.org"), &own), "alice@other.org");
// short() omits the default port, so both spellings of our own host
// agree and cannot produce two slugs for one author.
assert_eq!(slug_of(&addr("alice@smol.place:1961"), &own), "alice");
}
/// The reason this compares `store.account()` and not `smolmail_host`:
/// same host, different port is a different server.
#[test]
fn a_different_port_on_the_same_host_is_a_different_server() {
let own = addr("twtxt@127.0.0.1:19611");
assert_eq!(slug_of(&addr("alice@127.0.0.1:19611"), &own), "alice");
assert_eq!(
slug_of(&addr("alice@127.0.0.1:19999"), &own),
"alice@127.0.0.1:19999"
);
}
#[test]
fn every_derived_slug_is_a_legal_feed_path() {
let own = addr("twtxt@smol.place");
for who in [
"alice@smol.place",
"alice@other.org",
"alice@127.0.0.1:19999",
"a.b@other.org",
"a_b@sub.other.org",
] {
let slug = slug_of(&addr(who), &own);
assert!(
crate::feedstore::valid_slug(&slug),
"{who} derived the unusable slug {slug:?}"
);
}
}
}