use rand_core::CryptoRng;
use subtle::ConstantTimeEq;
use time::OffsetDateTime;
use zeroize::Zeroize;
use super::{
Client, Effect, LinkKind, LinkToken, Refusal, Transition, UNEXPECTED, audit, complete_sign_in,
failure, issue_link, start_session,
};
use crate::Error;
use crate::account::Account;
use crate::audit::AuditKind;
use crate::config::Config;
use crate::error::InvalidRequestReason;
use crate::http::SetCookie;
use crate::ids::UserId;
use crate::mail::{Mail, MailKind};
use crate::session::{Session, SignInMethod, Stage};
use crate::token::{self, SecretToken, TokenHash, sha256};
pub const DEFAULT_CODE_DIGITS: u8 = 6;
pub const DEFAULT_CODE_ATTEMPTS: u32 = 5;
const MIN_DIGITS: u8 = 6;
const MAX_DIGITS: u8 = 10;
const HASH_DOMAIN: &[u8] = b"latchkey-email-code\0";
#[derive(Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct EmailCode(String);
impl EmailCode {
#[must_use]
pub fn expose_secret(&self) -> &str {
&self.0
}
}
impl Drop for EmailCode {
fn drop(&mut self) {
self.0.zeroize();
}
}
impl std::fmt::Debug for EmailCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("EmailCode(<redacted>)")
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct EmailCodeHash([u8; 32]);
impl EmailCodeHash {
#[must_use]
pub fn of(binding: &str, code: &str) -> Option<Self> {
let binding = binding.trim();
TokenHash::of(binding)?;
let mut digits = normalize_code(code)?;
let mut input = Vec::with_capacity(HASH_DOMAIN.len() + binding.len() + 1 + digits.len());
input.extend_from_slice(HASH_DOMAIN);
input.extend_from_slice(binding.as_bytes());
input.push(0);
input.extend_from_slice(digits.as_bytes());
let hash = Self(sha256(&input));
input.zeroize();
digits.zeroize();
Some(hash)
}
#[must_use]
pub fn verify(&self, binding: &str, code: &str) -> bool {
Self::of(binding, code).is_some_and(|presented| presented.0.ct_eq(&self.0).into())
}
#[must_use]
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl TryFrom<&[u8]> for EmailCodeHash {
type Error = Error;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
<[u8; 32]>::try_from(bytes)
.map(Self)
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::TokenHashLength))
}
}
impl AsRef<[u8]> for EmailCodeHash {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for EmailCodeHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"EmailCodeHash({})",
crate::hex(self.0.get(..4).unwrap_or_default())
)
}
}
fn normalize_code(code: &str) -> Option<String> {
let digits: String = code
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.collect();
let shaped = (usize::from(MIN_DIGITS)..=usize::from(MAX_DIGITS)).contains(&digits.len())
&& digits.bytes().all(|b| b.is_ascii_digit());
shaped.then_some(digits)
}
#[must_use]
pub fn issue_code<R: CryptoRng + ?Sized>(
binding: &SecretToken,
digits: u8,
rng: &mut R,
) -> (EmailCode, EmailCodeHash) {
let digits = digits.clamp(MIN_DIGITS, MAX_DIGITS);
let space = 10u64.pow(u32::from(digits));
let zone = u64::MAX - (u64::MAX % space);
let value = loop {
let mut bytes = [0u8; 8];
rng.fill_bytes(&mut bytes);
let drawn = u64::from_le_bytes(bytes);
bytes.zeroize();
if drawn < zone {
break drawn % space;
}
};
let code = EmailCode(format!("{value:0width$}", width = usize::from(digits)));
let hash = EmailCodeHash::of(binding.expose_secret(), code.expose_secret())
.unwrap_or(EmailCodeHash([0; 32]));
(code, hash)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct EmailCodeRequest {
pub user_id: UserId,
pub link_hash: TokenHash,
pub code_hash: EmailCodeHash,
pub created_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
pub attempts: u32,
}
impl EmailCodeRequest {
#[must_use]
pub const fn new(
user_id: UserId,
link_hash: TokenHash,
code_hash: EmailCodeHash,
created_at: OffsetDateTime,
expires_at: OffsetDateTime,
attempts: u32,
) -> Self {
Self {
user_id,
link_hash,
code_hash,
created_at,
expires_at,
attempts,
}
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum EmailLink {
Start,
Requested,
SecondFactor {
account: Account,
session: Session,
},
SignedIn {
account: Account,
session: Session,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum EmailLinkEvent {
Request {
account: Option<Account>,
last_sent_at: Option<OffsetDateTime>,
binding: Option<SecretToken>,
client: Client,
},
Redeem {
token_hash: Option<TokenHash>,
link: Option<LinkToken>,
account: Option<Account>,
client: Client,
},
RedeemCode {
binding: Option<SecretToken>,
code: String,
request: Option<EmailCodeRequest>,
account: Option<Account>,
client: Client,
},
}
impl EmailLink {
pub fn advance<R: CryptoRng + ?Sized>(
self,
config: &Config,
event: EmailLinkEvent,
now: OffsetDateTime,
rng: &mut R,
) -> Result<Transition<EmailLink>, Refusal> {
match (self, event) {
(
EmailLink::Start,
EmailLinkEvent::Request {
account,
last_sent_at,
binding,
client,
},
) => {
let binding = binding
.filter(|binding| binding.hash().is_some())
.unwrap_or_else(|| token::issue(rng).token);
let mut effects = vec![Effect::SetCookie(SetCookie::new(
config.email_cookie_name().clone(),
&binding,
config.link_ttl(),
))];
let cooling = last_sent_at
.is_some_and(|last| last.saturating_add(config.resend_cooldown()) > now);
if let Some(account) = account.filter(|account| account.active && !cooling) {
effects.extend(send_sign_in_mail(
config, &account, &binding, &client, now, rng,
));
}
Ok(Transition {
state: EmailLink::Requested,
effects,
})
}
(
EmailLink::Start,
EmailLinkEvent::Redeem {
token_hash,
link,
account,
client,
},
) => {
let valid = |account: &Account| {
account.active
&& link.is_some_and(|link| {
link.is_redeemable(LinkKind::SignIn, now) && link.user_id == account.id
})
};
let Some(account) = account.filter(valid) else {
return Err(Error::SignInFailed.into());
};
let mut effects: Vec<Effect> = token_hash
.map(|link_hash| Effect::DeleteEmailCode { link_hash })
.into_iter()
.collect();
let (state, done) = first_factor_accepted(
config,
account,
&SignInMethod::EmailLink,
&client,
now,
rng,
);
effects.extend(done);
Ok(Transition { state, effects })
}
(
EmailLink::Start,
EmailLinkEvent::RedeemCode {
binding,
code,
request,
account,
client,
},
) => {
let presented = PresentedCode {
binding: binding.as_ref(),
code: &code,
request,
account,
};
presented.redeem(config, &client, now, rng)
}
_ => Err(UNEXPECTED.into()),
}
}
}
struct PresentedCode<'a> {
binding: Option<&'a SecretToken>,
code: &'a str,
request: Option<EmailCodeRequest>,
account: Option<Account>,
}
impl PresentedCode<'_> {
fn redeem<R: CryptoRng + ?Sized>(
self,
config: &Config,
client: &Client,
now: OffsetDateTime,
rng: &mut R,
) -> Result<Transition<EmailLink>, Refusal> {
let (Some(binding), Some(request)) = (self.binding, self.request) else {
return Err(Error::SignInFailed.into());
};
let Some(binding_hash) = binding.hash() else {
return Err(Error::SignInFailed.into());
};
let end = || {
vec![
Effect::DeleteEmailSignIn {
binding_hash,
link_hash: request.link_hash,
},
Effect::SetCookie(SetCookie::removal(config.email_cookie_name().clone())),
]
};
let account = self
.account
.filter(|account| account.active && account.id == request.user_id);
let Some(account) = account else {
return Err(Refusal::new(Error::SignInFailed, end()));
};
let budget = config.email_code_attempts();
if request.expires_at <= now || request.attempts > budget {
return Err(Refusal::new(Error::SignInFailed, end()));
}
account.lockout.check(now)?;
if !request.code_hash.verify(binding.expose_secret(), self.code) {
let mut effects = failure(&account, "email_code", client, now);
if request.attempts >= budget {
effects.extend(end());
}
return Err(Refusal::new(Error::SignInFailed, effects));
}
let mut effects = vec![
Effect::ConsumeEmailCode {
binding_hash,
link_hash: request.link_hash,
},
Effect::SetCookie(SetCookie::removal(config.email_cookie_name().clone())),
];
let (state, done) =
first_factor_accepted(config, account, &SignInMethod::EmailCode, client, now, rng);
effects.extend(done);
Ok(Transition { state, effects })
}
}
fn first_factor_accepted<R: CryptoRng + ?Sized>(
config: &Config,
account: Account,
method: &SignInMethod,
client: &Client,
now: OffsetDateTime,
rng: &mut R,
) -> (EmailLink, Vec<Effect>) {
if account.factors.password_suffices(config.password_only()) {
let (session, effects) = complete_sign_in(config, &account, method, client, None, now, rng);
return (EmailLink::SignedIn { account, session }, effects);
}
let (session, effects) = start_session(
config,
account.id,
Stage::SecondFactor,
Some(method.clone()),
client,
None,
now,
rng,
);
(EmailLink::SecondFactor { account, session }, effects)
}
fn send_sign_in_mail<R: CryptoRng + ?Sized>(
config: &Config,
account: &Account,
binding: &SecretToken,
client: &Client,
now: OffsetDateTime,
rng: &mut R,
) -> Vec<Effect> {
let issued = issue_link(config, account, LinkKind::SignIn, now, rng);
let (code, code_hash) = issue_code(binding, config.email_code_digits(), rng);
let mut effects = Vec::with_capacity(4);
if let Some(binding_hash) = binding.hash() {
effects.push(Effect::StoreEmailCode {
binding_hash,
request: EmailCodeRequest::new(
account.id,
issued.token_hash,
code_hash,
now,
issued.link.expires_at,
0,
),
});
}
effects.push(Effect::StoreLinkToken {
token_hash: issued.token_hash,
link: issued.link,
});
effects.push(Effect::SendMail(
Mail::new(
MailKind::SignInLink,
account.email.clone(),
issued.url,
Some(issued.link.expires_at),
None,
)
.with_code(code),
));
effects.push(Effect::Audit(audit(
AuditKind::SignInLinkSent,
Some(account.id),
client,
now,
)));
effects
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_are_six_digits_and_bound_to_their_binding() {
let mut rng = crate::test_rng();
let binding = token::issue(&mut rng).token;
let other = token::issue(&mut rng).token;
for _ in 0..200 {
let (code, hash) = issue_code(&binding, DEFAULT_CODE_DIGITS, &mut rng);
let digits = code.expose_secret();
assert_eq!(digits.len(), 6);
assert!(digits.bytes().all(|b| b.is_ascii_digit()));
assert!(hash.verify(binding.expose_secret(), digits));
assert!(!hash.verify(other.expose_secret(), digits));
}
}
#[test]
fn codes_have_the_digits_asked_for_within_the_bounds() {
let mut rng = crate::test_rng();
let binding = token::issue(&mut rng).token;
for (asked, got) in [
(6, 6),
(8, 8),
(10, 10),
(0, 6),
(4, 6),
(12, 10),
(255, 10),
] {
for _ in 0..50 {
let (code, hash) = issue_code(&binding, asked, &mut rng);
let digits = code.expose_secret();
assert_eq!(digits.len(), got, "asked for {asked}");
assert!(digits.bytes().all(|b| b.is_ascii_digit()));
assert!(hash.verify(binding.expose_secret(), digits));
}
}
}
#[test]
fn codes_ignore_spaces_and_dashes_and_refuse_other_shapes() {
let binding = token::issue(&mut crate::test_rng()).token;
let b = binding.expose_secret();
let hash = EmailCodeHash::of(b, "123456").unwrap();
assert!(hash.verify(b, " 123 456 "));
assert!(hash.verify(b, "123-456"));
assert!(!hash.verify(b, "123457"));
assert_eq!(EmailCodeHash::of(b, "12345"), None);
assert_eq!(EmailCodeHash::of(b, "12345678901"), None);
let eight = EmailCodeHash::of(b, "1234-5678").unwrap();
assert!(eight.verify(b, "12345678"));
assert!(!eight.verify(b, "123456"));
assert!(!hash.verify(b, "12345600"));
assert_eq!(EmailCodeHash::of(b, "12345a"), None);
assert_eq!(
EmailCodeHash::of(b, "\u{661}\u{662}\u{663}\u{664}\u{665}\u{666}"),
None,
"ASCII digits only"
);
assert_eq!(EmailCodeHash::of("not a token", "123456"), None);
}
#[test]
fn codes_are_redacted_and_hashes_round_trip() {
let mut rng = crate::test_rng();
let binding = token::issue(&mut rng).token;
let (code, hash) = issue_code(&binding, DEFAULT_CODE_DIGITS, &mut rng);
assert!(!format!("{code:?}").contains(code.expose_secret()));
let bytes: &[u8] = hash.as_ref();
assert_eq!(EmailCodeHash::try_from(bytes), Ok(hash));
assert_eq!(EmailCodeHash::from_bytes(*hash.as_bytes()), hash);
assert!(EmailCodeHash::try_from(&bytes[..31]).is_err());
}
}