Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-estate-side/src/sign_in.rs

//! **A resend sign-in link, minted and mailed by the estate itself** (Gunnar's S10, owner
//! 2026-10-11; Holger's A16; SHARED since P4c, A28 M11): a sold estate's buyer lost or let run
//! out the one link the ready mail carried. monetize queues a signed `sign_in_again` order for
//! the estate's main naming whose passkey, which address and which console host — never a
//! secret. The main verifies it, mints a fresh passkey invite on that account (only while it
//! holds no passkey) and mails the link itself. Pure (std only, no I/O): the product mints and
//! mails; this is the order's checks, the lifetime and the words. Gunnar's gunnar-repair
//! `sign_in` is the same code until T16 (H20).
//!
//! The link has the shape of the first one (monetize's `first_login_link`):
//! `https://{host}/login?invite={secret}&user={account}`. Pure: the order's checks, the
//! invite's lifetime, the link and the mail's words.

/// The shortest invite a resend mints.
pub const MIN_TTL_SECS: u64 = 3_600;
/// The longest: an invite lives 30 days at most.
pub const MAX_TTL_SECS: u64 = 30 * 86_400;
/// An order that names no lifetime.
pub const DEFAULT_TTL_SECS: u64 = 7 * 86_400;

/// **Can this box mail this resend?** `account` goes into the link, `to` into a mail header,
/// `host` into the link's authority: none may carry anything but itself (no second header,
/// no other path, no query of its own).
pub fn check(account: &str, to: &str, host: &str) -> Result<(), String> {
    let name = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.');
    if account.is_empty() || account.len() > 128 || !account.chars().all(name) {
        return Err(format!(
            "the account {account:?} is not a name a link can carry"
        ));
    }
    let mut parts = to.split('@');
    let (local, domain) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
    let plain = |c: char| {
        c.is_ascii_graphic()
            && !matches!(
                c,
                '<' | '>' | ',' | ';' | '"' | '\\' | '(' | ')' | '[' | ']' | ':'
            )
    };
    if parts.next().is_some()
        || local.is_empty()
        || domain.is_empty()
        || !domain.contains('.')
        || to.len() > 254
        || !to.chars().all(plain)
    {
        return Err(format!("{to:?} is not one mail address"));
    }
    let label = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '.');
    if host.is_empty()
        || host.len() > 253
        || !host.chars().all(label)
        || host.starts_with('.')
        || host.ends_with('.')
    {
        return Err(format!("{host:?} is not a host name"));
    }
    Ok(())
}

/// The invite's lifetime: what the order asked, within [`MIN_TTL_SECS`]..=[`MAX_TTL_SECS`];
/// [`DEFAULT_TTL_SECS`] when it asked none.
pub fn ttl(asked_secs: u64) -> u64 {
    if asked_secs == 0 {
        DEFAULT_TTL_SECS
    } else {
        asked_secs.clamp(MIN_TTL_SECS, MAX_TTL_SECS)
    }
}

/// The link, the same shape as the first one.
pub fn link(host: &str, secret: &str, account: &str) -> String {
    format!("https://{host}/login?invite={secret}&user={account}")
}

/// A lifetime in words: whole days as days, else hours.
pub fn ttl_words(secs: u64) -> String {
    match secs {
        s if s >= 86_400 && s % 86_400 == 0 => {
            format!("{} day{}", s / 86_400, if s == 86_400 { "" } else { "s" })
        }
        s => format!(
            "{} hour{}",
            (s / 3_600).max(1),
            if s / 3_600 <= 1 { "" } else { "s" }
        ),
    }
}

/// **The mail the estate sends**: to the order's address, the link in its body and nowhere
/// else. Its `Debug` withholds the body (it carries the secret).
#[derive(Clone, PartialEq, Eq)]
pub struct SignInMail {
    pub to: String,
    pub subject: String,
    pub body: String,
}

impl std::fmt::Debug for SignInMail {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "SignInMail {{ to: {:?}, subject: {:?}, body: <withheld> }}",
            self.to, self.subject
        )
    }
}

/// The mail for `account` on `host`, its invite `secret` living `ttl_secs`.
pub fn mail(host: &str, account: &str, to: &str, secret: &str, ttl_secs: u64) -> SignInMail {
    let ttl = ttl_words(ttl_secs);
    let link = link(host, secret, account);
    let body = format!(
        "You asked for a new sign-in link for {host}. It registers your passkey on {account}, and it works once, for {ttl}:\n\n  {link}\n\n\
         After that, sign in at https://{host} with your passkey. If you did not ask for this, you can ignore this mail: the link is only for {account}, who has no passkey yet.\n"
    );
    SignInMail {
        to: to.to_string(),
        subject: format!("Your new sign-in link for https://{host}"),
        body,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_link_has_the_first_links_shape_and_the_mail_carries_it_once() {
        assert_eq!(
            link("acme.holger.mock.invalid", "s3cret", "acme"),
            "https://acme.holger.mock.invalid/login?invite=s3cret&user=acme"
        );
        let m = mail(
            "acme.holger.mock.invalid",
            "acme",
            "buyer@mock.invalid",
            "s3cret",
            7 * 86_400,
        );
        assert_eq!(m.to, "buyer@mock.invalid");
        assert_eq!(
            m.body
                .matches("https://acme.holger.mock.invalid/login?invite=s3cret&user=acme")
                .count(),
            1,
            "{}",
            m.body
        );
        assert!(m.body.contains("7 days") && !m.subject.contains("s3cret"));
        assert!(
            !format!("{m:?}").contains("s3cret"),
            "Debug withholds the secret"
        );
    }

    #[test]
    fn the_lifetime_is_what_was_asked_within_an_hour_and_thirty_days() {
        assert_eq!(
            (ttl(0), ttl(60), ttl(86_400), ttl(365 * 86_400)),
            (DEFAULT_TTL_SECS, MIN_TTL_SECS, 86_400, MAX_TTL_SECS)
        );
        assert_eq!(
            (
                ttl_words(86_400),
                ttl_words(3 * 86_400),
                ttl_words(3_600),
                ttl_words(7_200)
            ),
            (
                "1 day".into(),
                "3 days".into(),
                "1 hour".into(),
                "2 hours".into()
            )
        );
    }

    #[test]
    fn nothing_but_a_name_an_address_and_a_host_passes() {
        assert!(check("acme", "buyer@mock.invalid", "acme.holger.mock.invalid").is_ok());
        assert!(check(
            "first.admin-1",
            "a.b+c@sub.mock.invalid",
            "acme.mock.invalid"
        )
        .is_ok());
        for (a, t, h) in [
            ("", "b@e.mock.invalid", "h.mock.invalid"),
            ("al ice", "b@e.mock.invalid", "h.mock.invalid"),
            ("a&user=x", "b@e.mock.invalid", "h.mock.invalid"),
            (
                "a",
                "b@e.mock.invalid\r\nBcc: x@evil.mock.invalid",
                "h.mock.invalid",
            ),
            ("a", "b@@e.mock.invalid", "h.mock.invalid"),
            ("a", "Bob <b@e.mock.invalid>", "h.mock.invalid"),
            ("a", "b@localhost", "h.mock.invalid"),
            ("a", "b@e.mock.invalid", ""),
            ("a", "b@e.mock.invalid", "evil.mock.invalid/x?"),
            ("a", "b@e.mock.invalid", "user@evil.mock.invalid"),
        ] {
            assert!(check(a, t, h).is_err(), "{a:?} {t:?} {h:?}");
        }
    }
}