use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use hmac::{Hmac, Mac};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use secrecy::{ExposeSecret, SecretSlice};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::config::AppConfig;
use crate::crypt::{AppKey, Clock, SignedUrlError, UrlSigner};
const BINDING_LABEL: &[u8] = b"arcature/auth-flows/email-binding";
const DEFAULT_PATH: &str = "/email/verify";
const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
const DEFAULT_VALID_FOR: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum EmailVerificationError {
Link(SignedUrlError),
AddressChanged,
}
impl fmt::Display for EmailVerificationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Link(source) => write!(formatter, "verification link: {source}"),
Self::AddressChanged => formatter.write_str(
"the verification link was minted for a different address than the account now \
has",
),
}
}
}
impl std::error::Error for EmailVerificationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Link(source) => Some(source),
Self::AddressChanged => None,
}
}
}
impl From<SignedUrlError> for EmailVerificationError {
fn from(source: SignedUrlError) -> Self {
Self::Link(source)
}
}
pub struct EmailVerification {
signer: UrlSigner,
binding_key: SecretSlice<u8>,
path: String,
valid_for: Duration,
}
impl EmailVerification {
#[must_use]
pub fn new(key: &AppKey, config: &AppConfig) -> Self {
Self {
signer: UrlSigner::new(key, config),
binding_key: key.subkey(BINDING_LABEL),
path: DEFAULT_PATH.to_owned(),
valid_for: DEFAULT_VALID_FOR,
}
}
#[must_use]
pub fn path(mut self, prefix: impl Into<String>) -> Self {
self.path = prefix.into();
self
}
#[must_use]
pub fn valid_for(mut self, valid_for: Duration) -> Self {
self.valid_for = valid_for;
self
}
#[must_use]
pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
self.signer = self.signer.with_clock(clock);
self
}
pub fn link(&self, user_key: &str, email: &str) -> Result<String, EmailVerificationError> {
let binding = self.binding_of(user_key, email);
let path = format!(
"{}/{}/{}",
self.path.trim_end_matches('/'),
encode_segment(user_key),
binding
);
Ok(self.signer.sign_temporary(&path, &[], self.valid_for)?)
}
pub fn confirm(
&self,
url: &str,
user_key: &str,
binding: &str,
current_email: &str,
) -> Result<(), EmailVerificationError> {
self.signer.verify(url)?;
let expected = self.binding_of(user_key, current_email);
if !bool::from(binding.as_bytes().ct_eq(expected.as_bytes())) {
return Err(EmailVerificationError::AddressChanged);
}
Ok(())
}
fn binding_of(&self, user_key: &str, email: &str) -> String {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(self.binding_key.expose_secret())
.expect("HMAC-SHA256 accepts a key of any length");
feed(&mut mac, user_key.as_bytes());
feed(&mut mac, email.as_bytes());
crate::crypt::base64url::encode(&mac.finalize().into_bytes())
}
}
impl fmt::Debug for EmailVerification {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("EmailVerification")
.field("path", &self.path)
.field("valid_for", &self.valid_for)
.finish_non_exhaustive()
}
}
fn feed(mac: &mut Hmac<Sha256>, bytes: &[u8]) {
mac.update(&(bytes.len() as u64).to_be_bytes());
mac.update(bytes);
}
fn encode_segment(segment: &str) -> String {
utf8_percent_encode(segment, UNRESERVED).to_string()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use super::{EmailVerification, EmailVerificationError, encode_segment};
use crate::config::AppConfig;
use crate::crypt::{AppKey, Clock, SignedUrlError};
struct Frozen(u64);
impl Clock for Frozen {
fn now_unix(&self) -> u64 {
self.0
}
}
fn key() -> AppKey {
AppKey::from_hex(&"4a".repeat(64)).expect("valid key")
}
fn flow() -> EmailVerification {
EmailVerification::new(&key(), &AppConfig::new().url("https://acme.test"))
}
fn binding_of(link: &str) -> &str {
link.split('?')
.next()
.and_then(|path| path.rsplit('/').next())
.expect("a link has a last segment")
}
#[test]
fn a_fresh_link_confirms_the_address_it_was_minted_for() {
let flow = flow();
let link = flow.link("user:42", "ada@example.com").expect("sign");
assert_eq!(
flow.confirm(&link, "user:42", binding_of(&link), "ada@example.com"),
Ok(())
);
}
#[test]
fn a_link_kept_across_an_address_change_no_longer_confirms() {
let flow = flow();
let link = flow.link("user:42", "attacker@evil.test").expect("sign");
assert_eq!(
flow.confirm(&link, "user:42", binding_of(&link), "victim@bank.test"),
Err(EmailVerificationError::AddressChanged),
"a link minted for one address must not verify another"
);
}
#[test]
fn a_link_does_not_confirm_a_different_user() {
let flow = flow();
let link = flow.link("user:42", "ada@example.com").expect("sign");
assert_eq!(
flow.confirm(&link, "user:99", binding_of(&link), "ada@example.com"),
Err(EmailVerificationError::AddressChanged)
);
}
#[test]
fn an_edited_link_is_refused_before_the_binding_is_looked_at() {
let flow = flow();
let link = flow.link("user:42", "ada@example.com").expect("sign");
let binding = binding_of(&link).to_owned();
let marker = "signature=v1.";
let body = link.find(marker).expect("a signed link") + marker.len();
let flipped = if link[body..].starts_with('A') {
'B'
} else {
'A'
};
let tampered = format!("{}{flipped}{}", &link[..body], &link[body + 1..]);
assert_eq!(
flow.confirm(&tampered, "user:42", &binding, "ada@example.com"),
Err(EmailVerificationError::Link(SignedUrlError::Mismatch)),
"tampering must be reported as tampering, not as a changed address"
);
}
#[test]
fn a_link_stops_confirming_after_its_deadline() {
let minted = EmailVerification::new(&key(), &AppConfig::new().url("https://acme.test"))
.valid_for(Duration::from_secs(600))
.with_clock(Arc::new(Frozen(1_000)));
let link = minted.link("user:42", "ada@example.com").expect("sign");
let binding = binding_of(&link).to_owned();
let at_deadline = flow().with_clock(Arc::new(Frozen(1_600)));
assert_eq!(
at_deadline.confirm(&link, "user:42", &binding, "ada@example.com"),
Ok(()),
"a link is valid up to and including its expiry second"
);
let after = flow().with_clock(Arc::new(Frozen(1_601)));
assert_eq!(
after.confirm(&link, "user:42", &binding, "ada@example.com"),
Err(EmailVerificationError::Link(SignedUrlError::Expired))
);
}
#[test]
fn a_link_from_another_deployment_is_refused() {
let ours = flow();
let theirs = EmailVerification::new(
&AppKey::from_hex(&"7c".repeat(64)).expect("valid key"),
&AppConfig::new().url("https://acme.test"),
);
let link = theirs.link("user:42", "ada@example.com").expect("sign");
assert!(
ours.confirm(&link, "user:42", binding_of(&link), "ada@example.com")
.is_err()
);
}
#[test]
fn the_binding_is_keyed_and_not_a_bare_hash_of_the_address() {
let ours = flow();
let theirs = EmailVerification::new(
&AppKey::from_hex(&"7c".repeat(64)).expect("valid key"),
&AppConfig::new().url("https://acme.test"),
);
let a = ours.link("user:42", "ada@example.com").expect("sign");
let b = theirs.link("user:42", "ada@example.com").expect("sign");
assert_ne!(binding_of(&a), binding_of(&b));
}
#[test]
fn the_user_key_and_the_address_cannot_be_slid_across_each_other() {
let flow = flow();
let a = flow.link("ab", "c").expect("sign");
let b = flow.link("a", "bc").expect("sign");
assert_ne!(binding_of(&a), binding_of(&b));
}
#[test]
fn a_user_key_cannot_smuggle_a_path_segment() {
assert_eq!(encode_segment("user/42"), "user%2F42");
assert_eq!(encode_segment("user:42"), "user%3A42");
assert_eq!(encode_segment("a.b-c_d~e"), "a.b-c_d~e");
let flow = flow();
let link = flow.link("../../admin", "ada@example.com").expect("sign");
assert!(!link.contains("../"), "{link}");
}
#[test]
fn debug_does_not_print_key_material() {
let rendered = format!("{:?}", flow());
assert!(rendered.contains("/email/verify"), "{rendered}");
assert!(!rendered.to_lowercase().contains("key"), "{rendered}");
}
}