use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use crate::crypto::{
KeyType, decode_private_key_pem, encode_private_key_pem, generate_private_key,
};
use crate::error::{AcmeError, Result, StorageError};
use crate::storage::{Storage, account_key_prefix, safe_key};
const EMPTY_EMAIL: &str = "default";
const DEFAULT_REG_FILENAME: &str = "registration";
const DEFAULT_KEY_FILENAME: &str = "private";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcmeAccount {
#[serde(default)]
pub status: String,
#[serde(default)]
pub contact: Vec<String>,
#[serde(default)]
pub location: String,
#[serde(default)]
pub terms_of_service_agreed: bool,
#[serde(skip)]
pub private_key_pem: String,
#[serde(default)]
pub key_type: KeyType,
}
fn normalise_email(email: &str) -> String {
let email = email.strip_prefix("mailto:").unwrap_or(email);
let email = email.trim().to_lowercase();
if email.is_empty() {
EMPTY_EMAIL.to_owned()
} else {
email
}
}
fn email_username(email: &str) -> &str {
match email.find('@') {
None => email,
Some(0) => &email[1..],
Some(idx) => &email[..idx],
}
}
pub fn get_primary_contact(account: &AcmeAccount) -> String {
account
.contact
.first()
.map(|c| {
match c.find(':') {
Some(idx) => c[idx + 1..].to_owned(),
None => c.clone(),
}
})
.unwrap_or_default()
}
fn storage_key_user_reg(issuer_key: &str, email: &str) -> String {
let email = normalise_email(email);
let username = email_username(&email);
let filename = if username.is_empty() {
DEFAULT_REG_FILENAME.to_owned()
} else {
safe_key(username)
};
format!(
"{}/{}.json",
account_key_prefix(issuer_key, &email),
filename
)
}
fn storage_key_user_private_key(issuer_key: &str, email: &str) -> String {
let email = normalise_email(email);
let username = email_username(&email);
let filename = if username.is_empty() {
DEFAULT_KEY_FILENAME.to_owned()
} else {
safe_key(username)
};
format!(
"{}/{}.key",
account_key_prefix(issuer_key, &email),
filename
)
}
pub async fn get_account(
storage: &dyn Storage,
ca_url: &str,
email: &str,
) -> Result<Option<AcmeAccount>> {
let issuer_key = crate::storage::issuer_key(ca_url);
let reg_key = storage_key_user_reg(&issuer_key, email);
let pk_key = storage_key_user_private_key(&issuer_key, email);
let reg_bytes = match storage.load(®_key).await {
Ok(bytes) => bytes,
Err(crate::error::Error::Storage(StorageError::NotFound(_))) => return Ok(None),
Err(e) => return Err(e),
};
let key_bytes = match storage.load(&pk_key).await {
Ok(bytes) => bytes,
Err(crate::error::Error::Storage(StorageError::NotFound(_))) => return Ok(None),
Err(e) => return Err(e),
};
let mut account: AcmeAccount = serde_json::from_slice(®_bytes).map_err(|e| {
AcmeError::Account(format!("failed to deserialise account registration: {e}"))
})?;
let pem_str = String::from_utf8(key_bytes)
.map_err(|e| AcmeError::Account(format!("account private key is not valid UTF-8: {e}")))?;
let private_key = decode_private_key_pem(&pem_str)
.map_err(|e| AcmeError::Account(format!("could not decode account's private key: {e}")))?;
account.private_key_pem = pem_str;
account.key_type = private_key.key_type();
debug!(
email = email,
ca = ca_url,
"loaded existing ACME account from storage"
);
Ok(Some(account))
}
pub async fn save_account(
storage: &dyn Storage,
ca_url: &str,
account: &AcmeAccount,
) -> Result<()> {
let email = get_primary_contact(account);
let issuer_key = crate::storage::issuer_key(ca_url);
let reg_bytes = serde_json::to_vec_pretty(account).map_err(|e| {
AcmeError::Account(format!("failed to serialise account registration: {e}"))
})?;
let reg_key = storage_key_user_reg(&issuer_key, &email);
let pk_key = storage_key_user_private_key(&issuer_key, &email);
storage.store(®_key, ®_bytes).await?;
if let Err(e) = storage
.store(&pk_key, account.private_key_pem.as_bytes())
.await
{
let _ = storage.delete(®_key).await;
return Err(e);
}
debug!(
email = %email,
ca = ca_url,
"saved ACME account to storage"
);
Ok(())
}
pub fn new_account(email: &str, key_type: KeyType) -> Result<AcmeAccount> {
let private_key = generate_private_key(key_type)?;
let private_key_pem = encode_private_key_pem(&private_key)?;
let contact = if email.is_empty() {
Vec::new()
} else {
let email_normalised = normalise_email(email);
let email_addr = if email_normalised == EMPTY_EMAIL {
return Ok(AcmeAccount {
status: String::new(),
contact: Vec::new(),
location: String::new(),
terms_of_service_agreed: false,
private_key_pem,
key_type,
});
} else {
email_normalised
};
vec![format!("mailto:{email_addr}")]
};
Ok(AcmeAccount {
status: String::new(),
contact,
location: String::new(),
terms_of_service_agreed: false,
private_key_pem,
key_type,
})
}
pub async fn get_or_create_account(
storage: &dyn Storage,
ca_url: &str,
email: &str,
key_type: KeyType,
) -> Result<(AcmeAccount, bool)> {
match get_account(storage, ca_url, email).await? {
Some(account) => {
debug!(
email = email,
ca = ca_url,
"using existing ACME account from storage"
);
Ok((account, false))
}
None => {
info!(
email = email,
ca = ca_url,
"creating new ACME account (no existing account found in storage)"
);
let account = new_account(email, key_type)?;
Ok((account, true))
}
}
}
pub async fn delete_account_locally(
storage: &dyn Storage,
ca_url: &str,
account: &AcmeAccount,
) -> Result<()> {
let email = get_primary_contact(account);
let issuer_key = crate::storage::issuer_key(ca_url);
let reg_key = storage_key_user_reg(&issuer_key, &email);
let pk_key = storage_key_user_private_key(&issuer_key, &email);
storage.delete(®_key).await?;
storage.delete(&pk_key).await?;
debug!(
email = %email,
ca = ca_url,
"deleted local ACME account data"
);
Ok(())
}
pub async fn most_recent_account_email(
storage: &dyn Storage,
ca_url: &str,
) -> Result<Option<String>> {
let ik = crate::storage::issuer_key(ca_url);
let prefix = format!("{}/users", crate::storage::acme_ca_prefix(&ik));
let entries = match storage.list(&prefix, false).await {
Ok(e) => e,
Err(crate::error::Error::Storage(StorageError::NotFound(_))) => return Ok(None),
Err(e) => return Err(e),
};
if entries.is_empty() {
return Ok(None);
}
let mut best_email: Option<String> = None;
let mut best_modified: Option<chrono::DateTime<chrono::Utc>> = None;
for entry in &entries {
let info = match storage.stat(entry).await {
Ok(info) => info,
Err(_) => continue,
};
let email_part = entry.rsplit('/').next().unwrap_or("").to_string();
if email_part == "default" || email_part.is_empty() {
continue;
}
let dominated = best_modified.is_some_and(|bm| info.modified <= bm);
if !dominated {
best_modified = Some(info.modified);
best_email = Some(email_part);
}
}
Ok(best_email)
}
pub async fn get_account_by_key(
storage: &dyn Storage,
ca_url: &str,
key_pem: &str,
) -> Result<Option<AcmeAccount>> {
let ik = crate::storage::issuer_key(ca_url);
let prefix = format!("{}/users", crate::storage::acme_ca_prefix(&ik));
let entries = match storage.list(&prefix, false).await {
Ok(e) => e,
Err(crate::error::Error::Storage(StorageError::NotFound(_))) => return Ok(None),
Err(e) => return Err(e),
};
for entry in &entries {
let email_part = entry.rsplit('/').next().unwrap_or("").to_string();
if email_part.is_empty() {
continue;
}
match get_account(storage, ca_url, &email_part).await {
Ok(Some(account)) => {
if account.private_key_pem == key_pem {
return Ok(Some(account));
}
}
Ok(None) => continue,
Err(_) => continue,
}
}
Ok(None)
}
pub async fn discover_email(storage: &dyn Storage, ca_url: &str) -> Option<String> {
most_recent_account_email(storage, ca_url)
.await
.ok()
.flatten()
}
pub fn account_reg_lock_key(account: &AcmeAccount) -> String {
let mut key = "register_acme_account".to_owned();
if !account.contact.is_empty() {
let primary = get_primary_contact(account);
if !primary.is_empty() {
key.push('_');
key.push_str(&primary);
}
}
key
}
pub fn prompt_user_for_email() -> Option<String> {
use std::io::{self, BufRead, Write};
if !atty_is_terminal() {
return None;
}
eprint!("Your email address (for ACME account, Let's Encrypt notifications): ");
io::stderr().flush().ok();
let stdin = io::stdin();
let mut line = String::new();
if stdin.lock().read_line(&mut line).is_ok() {
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
} else {
None
}
}
pub fn prompt_user_agreement(tos_url: &str) -> bool {
use std::io::{self, BufRead, Write};
if !atty_is_terminal() {
return false;
}
eprintln!("\nYour CA's Terms of Service:");
eprintln!(" {tos_url}");
eprint!("Do you agree to the Terms of Service? (y/N): ");
io::stderr().flush().ok();
let stdin = io::stdin();
let mut line = String::new();
if stdin.lock().read_line(&mut line).is_ok() {
let answer = line.trim().to_lowercase();
answer == "y" || answer == "yes"
} else {
false
}
}
fn atty_is_terminal() -> bool {
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalise_strips_mailto() {
assert_eq!(
normalise_email("mailto:user@example.com"),
"user@example.com"
);
}
#[test]
fn normalise_lowercases() {
assert_eq!(normalise_email("User@Example.COM"), "user@example.com");
}
#[test]
fn normalise_trims_whitespace() {
assert_eq!(normalise_email(" user@example.com "), "user@example.com");
}
#[test]
fn normalise_empty_returns_default() {
assert_eq!(normalise_email(""), EMPTY_EMAIL);
assert_eq!(normalise_email(" "), EMPTY_EMAIL);
}
#[test]
fn email_username_normal() {
assert_eq!(email_username("user@example.com"), "user");
}
#[test]
fn email_username_no_at() {
assert_eq!(email_username("just-a-name"), "just-a-name");
}
#[test]
fn email_username_at_start() {
assert_eq!(email_username("@example.com"), "example.com");
}
#[test]
fn primary_contact_strips_mailto() {
let account = AcmeAccount {
status: String::new(),
contact: vec!["mailto:user@example.com".into()],
location: String::new(),
terms_of_service_agreed: false,
private_key_pem: String::new(),
key_type: KeyType::EcdsaP256,
};
assert_eq!(get_primary_contact(&account), "user@example.com");
}
#[test]
fn primary_contact_no_scheme() {
let account = AcmeAccount {
status: String::new(),
contact: vec!["user@example.com".into()],
location: String::new(),
terms_of_service_agreed: false,
private_key_pem: String::new(),
key_type: KeyType::EcdsaP256,
};
assert_eq!(get_primary_contact(&account), "user@example.com");
}
#[test]
fn primary_contact_empty_contacts() {
let account = AcmeAccount {
status: String::new(),
contact: vec![],
location: String::new(),
terms_of_service_agreed: false,
private_key_pem: String::new(),
key_type: KeyType::EcdsaP256,
};
assert_eq!(get_primary_contact(&account), "");
}
#[test]
fn storage_key_user_reg_with_email() {
let ik = crate::storage::issuer_key("https://acme.example.com/directory");
let key = storage_key_user_reg(&ik, "user@example.com");
assert!(key.starts_with("acme/"));
assert!(key.contains("/users/"));
assert!(key.ends_with("/user.json"));
}
#[test]
fn storage_key_user_reg_empty_email() {
let ik = crate::storage::issuer_key("https://acme.example.com/directory");
let key = storage_key_user_reg(&ik, "");
assert!(key.contains("/users/default/"));
assert!(key.ends_with(".json"));
}
#[test]
fn storage_key_user_private_key_with_email() {
let ik = crate::storage::issuer_key("https://acme.example.com/directory");
let key = storage_key_user_private_key(&ik, "user@example.com");
assert!(key.starts_with("acme/"));
assert!(key.contains("/users/"));
assert!(key.ends_with("/user.key"));
}
#[test]
fn new_account_with_email() {
let acct = new_account("user@example.com", KeyType::EcdsaP256).unwrap();
assert_eq!(acct.contact, vec!["mailto:user@example.com"]);
assert!(!acct.private_key_pem.is_empty());
assert_eq!(acct.key_type, KeyType::EcdsaP256);
assert!(acct.status.is_empty());
}
#[test]
fn new_account_empty_email() {
let acct = new_account("", KeyType::EcdsaP256).unwrap();
assert!(acct.contact.is_empty());
assert!(!acct.private_key_pem.is_empty());
}
#[test]
fn new_account_strips_mailto_prefix() {
let acct = new_account("mailto:user@example.com", KeyType::EcdsaP256).unwrap();
assert_eq!(acct.contact, vec!["mailto:user@example.com"]);
}
#[test]
fn lock_key_no_contact() {
let acct = AcmeAccount {
status: String::new(),
contact: vec![],
location: String::new(),
terms_of_service_agreed: false,
private_key_pem: String::new(),
key_type: KeyType::EcdsaP256,
};
assert_eq!(account_reg_lock_key(&acct), "register_acme_account");
}
#[test]
fn lock_key_with_contact() {
let acct = AcmeAccount {
status: String::new(),
contact: vec!["mailto:admin@example.com".into()],
location: String::new(),
terms_of_service_agreed: false,
private_key_pem: String::new(),
key_type: KeyType::EcdsaP256,
};
assert_eq!(
account_reg_lock_key(&acct),
"register_acme_account_admin@example.com"
);
}
}