pub const MIN_TTL_SECS: u64 = 3_600;
pub const MAX_TTL_SECS: u64 = 30 * 86_400;
pub const DEFAULT_TTL_SECS: u64 = 7 * 86_400;
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(())
}
pub fn ttl(asked_secs: u64) -> u64 {
if asked_secs == 0 {
DEFAULT_TTL_SECS
} else {
asked_secs.clamp(MIN_TTL_SECS, MAX_TTL_SECS)
}
}
pub fn link(host: &str, secret: &str, account: &str) -> String {
format!("https://{host}/login?invite={secret}&user={account}")
}
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" }
),
}
}
#[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
)
}
}
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:?}");
}
}
}