use std::net::IpAddr;
use std::sync::Arc;
use axum::http::StatusCode;
use base64::prelude::*;
use serde::Deserialize;
use serde_json::Value;
use tracing::{error, info, warn};
use uuid::Uuid;
use super::access::signer_account;
use super::error::Error;
use super::policy::{challenge_problem, check_identifiers};
use super::rules::{
check_csr_matches_order, csr_identifiers, is_wildcard, names_an_ip_address, normalize_dns_name,
parse_csr, parse_rfc3339, well_formed_name,
};
use crate::profile::Profile;
use acme_proxy_core::audit::RequestContext;
use acme_proxy_core::error::Problem;
use acme_proxy_core::identifier::Identifier;
use acme_proxy_core::jws::signature::jwk_thumbprint;
use acme_proxy_jobs::auditor::Auditor;
use acme_proxy_jobs::jobs::JobQueue;
use acme_proxy_jobs::notify::ChallengeFailedData;
use acme_proxy_jobs::notify::NotifyEvent;
use acme_proxy_net::challenge::ValidationContext;
use acme_proxy_policy::filter::IdentifierStage;
use acme_proxy_policy::filter::Stage as FilterStage;
use acme_proxy_store::account::Account;
use acme_proxy_store::authz::Authorization;
use acme_proxy_store::authz::Challenge;
use acme_proxy_store::authz::ValidationClaim;
use acme_proxy_store::db::Database;
use acme_proxy_store::nonce::now_secs;
use acme_proxy_store::order::Order;
use acme_proxy_store::status::AuthzStatus;
use acme_proxy_store::status::ChallengeStatus;
use acme_proxy_store::status::OrderStatus;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct NewOrderPayload {
pub identifiers: Vec<Identifier>,
#[serde(rename = "notBefore")]
pub not_before: Option<String>,
#[serde(rename = "notAfter")]
pub not_after: Option<String>,
pub replaces: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct FinalizePayload {
pub csr: String,
}
fn compound_identifier_problem(mut rejections: Vec<Problem>) -> Problem {
if rejections.len() == 1 {
return rejections.remove(0);
}
let status = rejections
.iter()
.map(Problem::status)
.max()
.unwrap_or(StatusCode::BAD_REQUEST);
Problem::compound(status, "Some of the identifiers requested were rejected")
.with_subproblems(rejections)
}
async fn check_replaces(
cert_id: &str,
profile: &str,
account_id: Uuid,
identifiers: &[Identifier],
database: &Arc<Database>,
) -> Result<String, Problem> {
let parsed = acme_proxy_core::cert::parse_ari_cert_id(cert_id).map_err(|error| {
warn!(event = "replaces_malformed", outcome = "failure", replaces = %cert_id, error = %error);
Problem::malformed(format!("Invalid `replaces` certID: {error}"))
})?;
let predecessor = Order::find_by_cert_serial(profile, &parsed.serial_hex(), database)
.await
.map_err(|error| {
error!(event = "replaces_lookup_failed", outcome = "failure", error = %error);
Problem::server_internal("Predecessor lookup failed")
})?
.ok_or_else(|| {
warn!(event = "replaces_unknown", outcome = "failure", replaces = %cert_id);
Problem::malformed("`replaces` names no certificate issued here")
})?;
if let Some(certificate) = predecessor.certificate.as_ref()
&& let Ok(leaf_der) = acme_proxy_core::cert::leaf_der_from_chain(certificate)
&& let Ok((aki, _)) = acme_proxy_core::cert::ari_cert_id_parts(&leaf_der)
&& aki != parsed.aki
{
warn!(event = "replaces_aki_mismatch", outcome = "failure", replaces = %cert_id);
return Err(Problem::malformed(
"`replaces` key identifier does not match the certificate",
));
}
if predecessor.account_id != account_id {
warn!(event = "replaces_wrong_account", outcome = "failure", replaces = %cert_id, order_id = %predecessor.id);
return Err(Problem::malformed(
"`replaces` names a certificate belonging to another account",
));
}
let shares_identifier = identifiers.iter().any(|wanted| {
predecessor
.identifiers
.iter()
.any(|had| had.typ == wanted.typ && had.value == wanted.value)
});
if !shares_identifier {
warn!(event = "replaces_no_shared_identifier", outcome = "failure", replaces = %cert_id);
return Err(Problem::malformed(
"`replaces` names a certificate sharing no identifier with this order",
));
}
if let Some(existing) = Order::find_by_replaces(profile, cert_id, database)
.await
.map_err(|error| {
error!(event = "replaces_conflict_lookup_failed", outcome = "failure", error = %error);
Problem::server_internal("Replacement lookup failed")
})?
{
warn!(
event = "replaces_already_claimed",
outcome = "failure",
replaces = %cert_id,
existing_order_id = %existing.id,
);
return Err(Problem::already_replaced(
"This certificate has already been marked as replaced by another order",
));
}
info!(event = "replaces_accepted", outcome = "success", replaces = %cert_id, predecessor_order_id = %predecessor.id);
Ok(cert_id.to_string())
}
fn is_replaces_conflict(error: &sqlx::Error) -> bool {
acme_proxy_store::sql::is_unique_violation_on(
error,
"orders.replaces",
"idx_orders_replaces_claim",
)
}
fn issue_failed(
profile: &str,
account_id: Uuid,
order: &Order,
client: &acme_proxy_core::audit::ClientContext,
reason: &'static str,
detail: &str,
) -> acme_proxy_core::audit::AuditRecord {
acme_proxy_core::audit::AuditRecord::new(
acme_proxy_core::audit::AuditEvent::CertificateIssueFailed,
profile,
acme_proxy_core::audit::Actor::acme(account_id),
)
.with_order(order.id, order.account_id, &order.identifiers)
.with_client(client.clone())
.with_reason(reason)
.with_detail(detail)
}
pub struct OrderService<'a> {
pub database: &'a Arc<Database>,
pub audit: &'a Auditor,
pub profile: &'a Profile,
}
fn validated_identifiers(
mut identifiers: Vec<Identifier>,
profile: &Profile,
) -> Result<Vec<Identifier>, Problem> {
let challenges = &profile.challenges;
if identifiers.is_empty() {
warn!(event = "order_no_identifiers", outcome = "failure");
return Err(Problem::malformed("No identifiers"));
}
if identifiers.len() > profile.order.max_identifiers {
warn!(
event = "order_too_many_identifiers",
outcome = "failure",
identifiers_count = identifiers.len(),
limit = profile.order.max_identifiers
);
return Err(Problem::malformed(format!(
"An order may name at most {} identifiers; this one names {}",
profile.order.max_identifiers,
identifiers.len()
)));
}
if let Some(bad) = identifiers.iter().find(|id| id.typ != "dns") {
warn!(event = "order_identifier_type_unsupported", outcome = "failure", typ = %bad.typ);
return Err(Problem::unsupported_identifier(
"Only dns identifiers supported",
));
}
for identifier in &mut identifiers {
identifier.value = normalize_dns_name(&identifier.value);
}
let mut seen = std::collections::HashSet::new();
identifiers.retain(|identifier| seen.insert(identifier.value.clone()));
let rejections: Vec<Problem> = identifiers
.iter()
.filter_map(|identifier| {
if !well_formed_name(&identifier.value) {
warn!(event = "order_identifier_malformed", outcome = "failure", value = %identifier.value);
Some(
Problem::malformed(format!(
"Malformed identifier {}: not a DNS name (a `*` is only legal as a single leading `*.`)",
identifier.value
))
.with_identifier(identifier),
)
} else if names_an_ip_address(&identifier.value) {
warn!(event = "order_identifier_is_address", outcome = "failure", value = %identifier.value);
Some(
Problem::rejected_identifier(format!(
"Identifier {} is an IP address, which a dns identifier cannot name",
identifier.value
))
.with_identifier(identifier),
)
} else if challenges
.types_for(is_wildcard(&identifier.value))
.is_empty()
{
warn!(event = "order_identifier_wildcard_rejected", outcome = "failure", value = %identifier.value);
Some(
Problem::rejected_identifier(format!(
"Wildcard identifier {} requires the dns-01 challenge, which is not enabled",
identifier.value
))
.with_identifier(identifier),
)
} else {
None
}
})
.collect();
if !rejections.is_empty() {
return Err(compound_identifier_problem(rejections));
}
Ok(identifiers)
}
impl OrderService<'_> {
pub async fn new_order(
&self,
payload: NewOrderPayload,
cached: Option<Account>,
pubkey: &[u8],
client_ip: Option<IpAddr>,
request: &RequestContext,
) -> Result<(Order, Vec<Uuid>), Error> {
let (database, profile, audit) = (self.database, self.profile, self.audit);
let identifiers = validated_identifiers(payload.identifiers, profile)?;
let not_before = match payload.not_before {
Some(ref s) => Some(parse_rfc3339("notBefore", s)?),
None => None,
};
let not_after = match payload.not_after {
Some(ref s) => Some(parse_rfc3339("notAfter", s)?),
None => None,
};
let account = signer_account(cached, &profile.name, pubkey, database).await?;
check_identifiers(
&profile.filter,
client_ip,
&account.id.to_string(),
&profile.name,
IdentifierStage::NewOrder,
&identifiers,
database,
)
.await?;
let replaces = match payload.replaces {
Some(ref cert_id) => Some(
check_replaces(cert_id, &profile.name, account.id, &identifiers, database).await?,
),
None => None,
};
let expires = now_secs() + profile.order.validity_seconds as i64;
let client = audit.client(request).await;
let mut order = Order::new(
&profile.name,
account.id,
identifiers,
expires,
not_before,
not_after,
)
.with_client(&client);
order.replaces = replaces;
let mut authz_ids = Vec::with_capacity(order.identifiers.len());
let persisted = async {
let mut tx = database.transaction().await?;
order.insert(tx.conn()).await?;
for identifier in &order.identifiers {
let authz = Authorization::new(order.id, identifier.clone(), order.expires);
authz.insert(tx.conn()).await?;
for typ in profile.challenges.types_for(is_wildcard(&identifier.value)) {
Challenge::new(authz.id, typ).insert(tx.conn()).await?;
}
authz_ids.push(authz.id);
}
tx.commit().await
}
.await;
persisted.map_err(|error| {
if is_replaces_conflict(&error) {
warn!(event = "replaces_claim_race_lost", outcome = "failure", account_id = %account.id);
return Problem::already_replaced(
"This certificate has already been marked as replaced by another order",
);
}
error!(
event = "order_creation_failed",
outcome = "failure",
error = %error,
account_id = %account.id
);
Problem::server_internal("Order persistence failed")
})?;
info!(
event = "order_created",
outcome = "success",
order_id = %order.id,
account_id = %account.id,
identifiers_count = order.identifiers.len()
);
Ok((order, authz_ids))
}
pub async fn deactivate_authz(
&self,
authz: &mut Authorization,
order: &mut Order,
) -> Result<(), Error> {
let database = self.database;
if authz.status == AuthzStatus::Deactivated {
return Ok(());
}
if order.status == OrderStatus::Valid {
warn!(event = "authz_deactivate_refused_order_valid", outcome = "failure", authz_id = %authz.id, order_id = %order.id);
return Err(Problem::malformed(
"Cannot deactivate an authorization whose order has already been issued; revoke the certificate instead",
)
.into());
}
if order.status == OrderStatus::Processing {
warn!(event = "authz_deactivate_refused_order_processing", outcome = "failure", authz_id = %authz.id, order_id = %order.id);
return Err(Problem::malformed(
"Cannot deactivate an authorization whose order is being issued",
)
.into());
}
if authz.status != AuthzStatus::Pending && authz.status != AuthzStatus::Valid {
warn!(event = "authz_deactivate_refused_terminal", outcome = "failure", authz_id = %authz.id, status = %authz.status);
return Err(Problem::malformed(
"Authorization is in a terminal state and cannot be deactivated",
)
.into());
}
let outcome = async {
let mut tx = database.transaction().await?;
let deactivated = Authorization::set_deactivated(authz.id, tx.conn()).await?;
let demoted = deactivated && Order::set_pending(order.id, tx.conn()).await?;
tx.commit().await?;
Ok::<_, sqlx::Error>((deactivated, demoted))
}
.await;
let (deactivated, demoted) = outcome.map_err(|error| {
error!(event = "authz_deactivate_failed", outcome = "failure", authz_id = %authz.id, error = %error);
Problem::server_internal("Authorization deactivation failed")
})?;
if !deactivated {
warn!(event = "authz_deactivate_refused_terminal", outcome = "failure", authz_id = %authz.id, status = %authz.status);
return Err(Problem::malformed(
"Authorization is in a terminal state and cannot be deactivated",
)
.into());
}
authz.status = AuthzStatus::Deactivated;
if demoted {
order.status = OrderStatus::Pending;
}
info!(event = "authz_deactivated", outcome = "success", authz_id = %authz.id, order_id = %order.id);
Ok(())
}
pub async fn claim_challenge(
&self,
challenge: &mut Challenge,
authz: &Authorization,
order: &Order,
) -> Result<ValidationClaim, Error> {
if authz.status != AuthzStatus::Valid && authz.expires <= now_secs() {
warn!(event = "authz_expired", outcome = "failure", authz_id = %authz.id, expires = authz.expires);
return Err(Problem::malformed("Authorization has expired").into());
}
if authz.status == AuthzStatus::Deactivated {
warn!(event = "authz_already_deactivated", outcome = "failure", authz_id = %authz.id);
return Err(Problem::malformed("Authorization has been deactivated").into());
}
let decided = challenge.status == ChallengeStatus::Valid
|| challenge.status == ChallengeStatus::Invalid
|| authz.status == AuthzStatus::Valid;
if decided {
return Ok(ValidationClaim::Decided);
}
if authz.status == AuthzStatus::Invalid || order.status == OrderStatus::Invalid {
let fresh = Challenge::find_by_id(challenge.id.to_string().as_str(), self.database)
.await
.map_err(|error| {
error!(event = "challenge_lookup_failed", outcome = "failure", challenge_id = %challenge.id, error = %error);
Problem::server_internal("Challenge lookup failed")
})?;
if let Some(fresh) = fresh
&& (fresh.status == ChallengeStatus::Valid
|| fresh.status == ChallengeStatus::Invalid)
{
*challenge = fresh;
return Ok(ValidationClaim::Decided);
}
warn!(event = "challenge_trigger_refused_invalid", outcome = "failure", authz_id = %authz.id, order_id = %order.id);
return Err(Problem::malformed(
"The authorization or its order is already invalid; create a new order",
)
.into());
}
let claimed = challenge
.claim_for_validation(self.profile.challenges.max_in_flight_per_account(), self.database)
.await
.map_err(|error| {
error!(event = "challenge_claim_failed", outcome = "failure", challenge_id = %challenge.id, error = %error);
Problem::server_internal("Challenge could not be claimed for validation")
})?;
if claimed == ValidationClaim::Limited {
warn!(event = "challenge_trigger_rate_limited", outcome = "failure", account_id = %order.account_id, challenge_id = %challenge.id);
}
Ok(claimed)
}
pub async fn run_validation(
&self,
account: &Account,
challenge: &mut Challenge,
authz: &mut Authorization,
order: &mut Order,
client_ip: Option<IpAddr>,
) -> Result<(), Error> {
let (database, profile) = (self.database, self.profile);
let thumbprint = jwk_thumbprint(&account.pubkey).map_err(|error| {
error!(event = "authz_thumbprint_failed", outcome = "failure", account_id = %account.id, error = %error);
Problem::server_internal("Key authorization could not be computed")
})?;
let key_authorization = format!("{}.{}", challenge.token, thumbprint);
let challenge_id = challenge.id.to_string();
let context = ValidationContext {
identifier: authz.base_identifier(),
wildcard: authz.is_wildcard(),
token: &challenge.token,
key_authorization: &key_authorization,
challenge_id: &challenge_id,
};
match profile.challenges.validate(&challenge.typ, &context).await {
Ok(()) => {
commit_validation(challenge, authz, order, database).await?;
}
Err(error) => {
let problem =
challenge_problem(&error, &challenge.typ, authz.base_identifier()).to_value();
warn!(
event = "challenge_failed",
outcome = "failure",
challenge_id = %challenge_id,
typ = %challenge.typ,
kind = error.kind()
);
let recorded =
commit_validation_failure(challenge, authz, order, &problem, database).await?;
if !recorded {
return Ok(());
}
profile
.notify
.dispatch(NotifyEvent::ChallengeFailed(ChallengeFailedData {
profile: profile.name.clone(),
order_id: order.id.to_string(),
account_id: account.id.to_string(),
authz_id: authz.id.to_string(),
challenge_id: challenge.id.clone().to_string(),
challenge_type: challenge.typ.clone(),
identifier: authz.base_identifier().to_string(),
error: error.kind().to_string(),
client_ip: client_ip
.map(|ip| acme_proxy_core::client::canonical(ip).to_string()),
}))
.await;
}
}
Ok(())
}
pub async fn abandon_validation(
&self,
challenge: &mut Challenge,
authz: &mut Authorization,
order: &mut Order,
reason: &str,
) -> Result<(), Error> {
let problem =
Problem::server_internal(format!("Challenge validation was not completed: {reason}"))
.to_value();
commit_validation_failure(challenge, authz, order, &problem, self.database).await?;
Ok(())
}
pub async fn finalize(
&self,
account: &Account,
mut order: Order,
csr: &str,
client_ip: Option<IpAddr>,
request: &RequestContext,
jobs: &JobQueue,
) -> Result<Order, Error> {
let (database, profile, audit) = (self.database, self.profile, self.audit);
let filter = &profile.filter;
let id = order.id.to_string();
if order.status != OrderStatus::Ready {
warn!(event = "order_finalize_not_ready", outcome = "failure", order_id = %id, status = %order.status);
return Err(Problem::order_not_ready("Order is not ready").into());
}
let client = audit.client(request).await;
let failed = |order: &Order, reason: &'static str, detail: &str| {
issue_failed(&profile.name, account.id, order, &client, reason, detail)
};
let csr_der = match BASE64_URL_SAFE_NO_PAD.decode(csr) {
Ok(der) => der,
Err(_) => {
audit
.record(failed(&order, "badCSR", "CSR base64 invalid"))
.await;
return Err(Problem::bad_csr("CSR base64 invalid").into());
}
};
let csr = match parse_csr(&csr_der) {
Ok(csr) => csr,
Err(problem) => {
audit
.record(failed(&order, "badCSR", "CSR is unparsable"))
.await;
return Err(problem.into());
}
};
if let Err(problem) = check_csr_matches_order(&csr, &csr_der, &order.identifiers) {
audit
.record(failed(
&order,
"badCSR",
"CSR identifiers do not match the order",
))
.await;
return Err(problem.into());
}
if filter.has_rules_at(FilterStage::Identifiers) {
let requested = csr_identifiers(&csr);
if let Err(problem) = check_identifiers(
filter,
client_ip,
order.account_id.to_string().as_str(),
&profile.name,
IdentifierStage::Csr,
&requested,
database,
)
.await
{
let (reason, detail) = if problem.status() == StatusCode::BAD_REQUEST {
("badCSR", "the filter policy refused the CSR identifiers")
} else {
(
"serverInternal",
"the filter policy could not be evaluated for the CSR identifiers",
)
};
audit.record(failed(&order, reason, detail)).await;
return Err(problem.into());
}
}
let spec = super::issue::signer_issue_spec(&order, &csr_der, &client, client_ip);
let claimed = async {
let mut tx = database.transaction().await?;
if !order.claim_for_finalize_on(tx.conn()).await? {
return Ok(false);
}
jobs.enqueue_in(&spec, tx.conn()).await?;
tx.commit().await?;
Ok::<bool, sqlx::Error>(true)
}
.await;
match claimed {
Ok(true) => {}
Ok(false) => {
warn!(
event = "order_finalize_claim_refused",
outcome = "failure",
order_id = %id
);
return Err(Problem::order_not_ready("Order is already being finalized").into());
}
Err(error) => {
error!(
event = "order_mark_processing_failed",
outcome = "failure",
order_id = %id,
error = %error
);
return Err(Problem::server_internal("Order finalize failed").into());
}
}
jobs.wake();
info!(event = "order_finalize_queued", outcome = "success", order_id = %id);
Ok(order)
}
}
async fn commit_validation(
challenge: &mut Challenge,
authz: &mut Authorization,
order: &mut Order,
database: &Arc<Database>,
) -> Result<(), Problem> {
let validated = now_secs();
let outcome = async {
let mut tx = database.transaction().await?;
let challenge_written = Challenge::set_valid(challenge.id, validated, tx.conn()).await?;
let authz_written =
challenge_written && Authorization::set_valid(authz.id, tx.conn()).await?;
let promoted = authz_written && {
let authzs = Authorization::find_by_order_with(order.id, tx.conn()).await?;
authzs.len() == order.identifiers.len()
&& authzs
.iter()
.all(|authz| authz.status == AuthzStatus::Valid)
&& Order::set_ready(order.id, tx.conn()).await?
};
tx.commit().await?;
Ok::<_, sqlx::Error>((challenge_written, authz_written, promoted))
}
.await;
match outcome {
Ok((challenge_written, authz_written, promoted)) => {
if challenge_written {
challenge.status = ChallengeStatus::Valid;
challenge.validated = Some(validated);
}
if authz_written {
authz.status = AuthzStatus::Valid;
} else if challenge_written {
info!(event = "challenge_verdict_superseded", outcome = "advisory", challenge_id = %challenge.id, authz_id = %authz.id);
}
if promoted {
order.status = OrderStatus::Ready;
}
Ok(())
}
Err(error) => {
error!(
event = "challenge_validation_persist_failed",
outcome = "failure",
challenge_id = %challenge.id,
authz_id = %authz.id,
order_id = %order.id,
error = %error
);
Err(Problem::server_internal("Challenge validation failed"))
}
}
}
async fn commit_validation_failure(
challenge: &mut Challenge,
authz: &mut Authorization,
order: &mut Order,
problem: &Value,
database: &Arc<Database>,
) -> Result<bool, Problem> {
let outcome = async {
let mut tx = database.transaction().await?;
let challenge_written = Challenge::set_invalid(challenge.id, problem, tx.conn()).await?;
let authz_written =
challenge_written && Authorization::set_invalid(authz.id, tx.conn()).await?;
let order_written =
authz_written && Order::set_invalid(order.id, problem, tx.conn()).await?;
tx.commit().await?;
Ok::<_, sqlx::Error>((challenge_written, authz_written, order_written))
}
.await;
match outcome {
Ok((challenge_written, authz_written, order_written)) => {
if challenge_written {
challenge.status = ChallengeStatus::Invalid;
challenge.error = Some(problem.clone());
}
if authz_written {
authz.status = AuthzStatus::Invalid;
} else if challenge_written {
info!(event = "challenge_verdict_superseded", outcome = "advisory", challenge_id = %challenge.id, authz_id = %authz.id);
}
if order_written {
order.status = OrderStatus::Invalid;
order.error = Some(problem.clone());
}
Ok(challenge_written)
}
Err(error) => {
error!(
event = "challenge_failure_persist_failed",
outcome = "failure",
challenge_id = %challenge.id,
authz_id = %authz.id,
order_id = %order.id,
error = %error
);
Err(Problem::server_internal("Challenge validation failed"))
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::profile::ProfileParts;
use acme_proxy_core::identifier::Identifier;
use acme_proxy_jobs::notify::NotifyDispatcher;
use acme_proxy_net::challenge::ChallengeError;
use acme_proxy_net::challenge::ChallengeRegistry;
use acme_proxy_net::challenge::ChallengeValidator;
use std::time::Duration;
pub(crate) fn profile(database: &Arc<Database>, challenges: ChallengeRegistry) -> Profile {
let ca = acme_proxy_signer::local_ca::LocalCa::generate_in_memory(
"ecdsa-p256",
90,
database.clone(),
)
.unwrap();
profile_with(database, challenges, Arc::new(ca))
}
pub(crate) fn profile_with(
database: &Arc<Database>,
challenges: ChallengeRegistry,
signer: Arc<dyn acme_proxy_signer::SignerBackend>,
) -> Profile {
Profile::new(
"default",
"http://localhost:3000",
ProfileParts {
signer_info: signer.info(),
filter: Arc::new(acme_proxy_policy::filter::FilterPolicy::default()),
challenges: Arc::new(challenges),
order: acme_proxy_core::config::OrderConfig::default(),
eab: acme_proxy_core::config::EabConfig::default(),
meta: acme_proxy_core::config::MetaConfig::default(),
notify: Arc::new(NotifyDispatcher::disabled(
acme_proxy_jobs::testutil::idle_job_queue(database.clone()),
)),
},
)
}
pub(crate) async fn account(database: &Arc<Database>) -> Account {
use rcgen::PublicKeyData;
let key = rcgen::KeyPair::generate().unwrap();
Account::find_or_create(
"default",
&key.subject_public_key_info(),
vec![],
&acme_proxy_core::audit::ClientContext::default(),
database,
)
.await
.unwrap()
.0
}
async fn pending_order(
database: &Arc<Database>,
account: &Account,
names: &[&str],
) -> (Order, Vec<(Authorization, Challenge)>) {
let order = Order::create(
"default",
account.id,
acme_proxy_store::testutil::dns_identifiers(names),
now_secs() + 3600,
None,
None,
database,
)
.await
.unwrap();
let mut authzs = Vec::new();
for name in names {
let authz =
Authorization::create(order.id, Identifier::dns(*name), order.expires, database)
.await
.unwrap();
let challenge = Challenge::create(authz.id, "http-01", database)
.await
.unwrap();
authzs.push((authz, challenge));
}
(order, authzs)
}
async fn reload(database: &Database, order: &Order) -> Order {
Order::find_by_id(&order.id.to_string(), database)
.await
.unwrap()
.unwrap()
}
async fn reload_authz(database: &Database, authz: &Authorization) -> Authorization {
Authorization::find_by_id(&authz.id.to_string(), database)
.await
.unwrap()
.unwrap()
}
struct Refusing;
#[async_trait::async_trait]
impl ChallengeValidator for Refusing {
fn typ(&self) -> &'static str {
"http-01"
}
async fn validate(&self, _ctx: &ValidationContext<'_>) -> Result<(), ChallengeError> {
Err(ChallengeError::IncorrectResponse("wrong body".into()))
}
}
#[tokio::test]
async fn deactivating_under_a_ready_order_demotes_it_in_the_same_write() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, ChallengeRegistry::default());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (mut order, mut authzs) = pending_order(&database, &account, &["a.example.com"]).await;
let (authz, challenge) = &mut authzs[0];
assert_eq!(
orders
.claim_challenge(challenge, authz, &order)
.await
.unwrap(),
ValidationClaim::Claimed
);
orders
.run_validation(&account, challenge, authz, &mut order, None)
.await
.unwrap();
assert_eq!(reload(&database, &order).await.status, OrderStatus::Ready);
orders.deactivate_authz(authz, &mut order).await.unwrap();
assert_eq!(authz.status, AuthzStatus::Deactivated);
assert_eq!(reload(&database, &order).await.status, OrderStatus::Pending);
orders.deactivate_authz(authz, &mut order).await.unwrap();
let refused = orders
.claim_challenge(challenge, authz, &order)
.await
.unwrap_err();
assert_eq!(
Problem::from(refused).to_value()["detail"],
"Authorization has been deactivated"
);
}
#[tokio::test]
async fn an_issued_order_refuses_deactivation() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, ChallengeRegistry::default());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (mut order, mut authzs) = pending_order(&database, &account, &["a.example.com"]).await;
order.status = OrderStatus::Valid;
let refused = orders
.deactivate_authz(&mut authzs[0].0, &mut order)
.await
.unwrap_err();
assert_eq!(Problem::from(refused).status(), 400);
assert_eq!(authzs[0].0.status, AuthzStatus::Pending);
}
#[tokio::test]
async fn a_challenge_is_claimed_once() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, ChallengeRegistry::default());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (order, mut authzs) = pending_order(&database, &account, &["a.example.com"]).await;
let (authz, challenge) = &mut authzs[0];
let mut twin = Challenge::find_by_id(&challenge.id.to_string(), &database)
.await
.unwrap()
.unwrap();
assert_eq!(
orders
.claim_challenge(challenge, authz, &order)
.await
.unwrap(),
ValidationClaim::Claimed
);
assert_eq!(
orders
.claim_challenge(&mut twin, authz, &order)
.await
.unwrap(),
ValidationClaim::Decided
);
}
#[tokio::test]
async fn concurrent_validations_of_one_order_promote_it() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, ChallengeRegistry::default());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (order, authzs) =
pending_order(&database, &account, &["a.example.com", "b.example.com"]).await;
let mut authzs = authzs.into_iter();
let (mut authz_a, mut challenge_a) = authzs.next().unwrap();
let (mut authz_b, mut challenge_b) = authzs.next().unwrap();
let (mut order_a, mut order_b) = (
reload(&database, &order).await,
reload(&database, &order).await,
);
let a = async {
assert_eq!(
orders
.claim_challenge(&mut challenge_a, &authz_a, &order_a)
.await
.unwrap(),
ValidationClaim::Claimed
);
orders
.run_validation(&account, &mut challenge_a, &mut authz_a, &mut order_a, None)
.await
.unwrap();
};
let b = async {
assert_eq!(
orders
.claim_challenge(&mut challenge_b, &authz_b, &order_b)
.await
.unwrap(),
ValidationClaim::Claimed
);
orders
.run_validation(&account, &mut challenge_b, &mut authz_b, &mut order_b, None)
.await
.unwrap();
};
tokio::join!(a, b);
assert_eq!(reload(&database, &order).await.status, OrderStatus::Ready);
}
#[tokio::test]
async fn a_failed_validation_invalidates_challenge_authorization_and_order_together() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(
&database,
ChallengeRegistry::new(
vec![Arc::new(Refusing)],
vec!["http-01".to_string()],
false,
Duration::from_secs(5),
),
);
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (mut order, mut authzs) = pending_order(&database, &account, &["a.example.com"]).await;
let (authz, challenge) = &mut authzs[0];
assert_eq!(
orders
.claim_challenge(challenge, authz, &order)
.await
.unwrap(),
ValidationClaim::Claimed
);
orders
.run_validation(&account, challenge, authz, &mut order, None)
.await
.expect("a refused validation is the challenge's answer, not an error");
let stored = Challenge::find_by_id(&challenge.id.to_string(), &database)
.await
.unwrap()
.unwrap();
assert_eq!(stored.status, ChallengeStatus::Invalid);
assert_eq!(
stored.error.unwrap()["type"],
"urn:ietf:params:acme:error:incorrectResponse"
);
let reloaded = reload(&database, &order).await;
assert_eq!(reloaded.status, OrderStatus::Invalid);
assert_eq!(authz.status, AuthzStatus::Invalid);
}
#[tokio::test]
async fn duplicate_identifiers_become_one() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, ChallengeRegistry::default());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let pubkey = account.pubkey.clone();
let payload = NewOrderPayload {
identifiers: vec![
Identifier::dns("A.example.com"),
Identifier::dns("a.example.com."),
],
..Default::default()
};
let (order, authz_ids) = orders
.new_order(
payload,
Some(account),
&pubkey,
None,
&RequestContext::default(),
)
.await
.unwrap();
assert_eq!(
order.identifiers,
acme_proxy_store::testutil::dns_identifiers(&["a.example.com"])
);
assert_eq!(authz_ids.len(), 1);
}
fn bypassing() -> ChallengeRegistry {
ChallengeRegistry::new(
vec![],
vec!["http-01".to_string(), "dns-01".to_string()],
true,
Duration::from_secs(5),
)
}
fn refusing() -> ChallengeRegistry {
ChallengeRegistry::new(
vec![Arc::new(Refusing)],
vec!["http-01".to_string()],
false,
Duration::from_secs(5),
)
}
#[tokio::test]
async fn a_late_sibling_failure_leaves_an_issued_order_valid() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let passing = profile(&database, bypassing());
let failing = profile(&database, refusing());
let audit = Auditor::offline(database.clone());
let account = account(&database).await;
let (mut order, mut authzs) = pending_order(&database, &account, &["a.example.com"]).await;
let (authz, http) = &mut authzs[0];
let mut dns = Challenge::create(authz.id, "dns-01", &database)
.await
.unwrap();
let on = |profile| OrderService {
database: &database,
audit: &audit,
profile,
};
assert_eq!(
on(&passing)
.claim_challenge(&mut dns, authz, &order)
.await
.unwrap(),
ValidationClaim::Claimed
);
assert_eq!(
on(&passing)
.claim_challenge(http, authz, &order)
.await
.unwrap(),
ValidationClaim::Claimed
);
let mut authz_seen_by_second = reload_authz(&database, authz).await;
let mut order_seen_by_second = reload(&database, &order).await;
on(&passing)
.run_validation(&account, &mut dns, authz, &mut order, None)
.await
.unwrap();
assert_eq!(reload(&database, &order).await.status, OrderStatus::Ready);
sqlx::query("UPDATE orders SET status = 'valid' WHERE id = ?;")
.bind(order.id)
.execute(database.raw_pool())
.await
.unwrap();
on(&failing)
.run_validation(
&account,
http,
&mut authz_seen_by_second,
&mut order_seen_by_second,
None,
)
.await
.unwrap();
assert_eq!(http.status, ChallengeStatus::Invalid);
assert_eq!(reload(&database, &order).await.status, OrderStatus::Valid);
assert_eq!(
reload_authz(&database, authz).await.status,
AuthzStatus::Valid
);
}
#[tokio::test]
async fn a_verdict_after_deactivation_leaves_the_authorization_deactivated() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, bypassing());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (mut order, mut authzs) = pending_order(&database, &account, &["a.example.com"]).await;
let (authz, challenge) = &mut authzs[0];
assert_eq!(
orders
.claim_challenge(challenge, authz, &order)
.await
.unwrap(),
ValidationClaim::Claimed
);
let mut authz_seen_by_job = reload_authz(&database, authz).await;
orders.deactivate_authz(authz, &mut order).await.unwrap();
orders
.run_validation(
&account,
challenge,
&mut authz_seen_by_job,
&mut order,
None,
)
.await
.unwrap();
assert_eq!(
reload_authz(&database, authz).await.status,
AuthzStatus::Deactivated
);
assert_eq!(reload(&database, &order).await.status, OrderStatus::Pending);
}
#[tokio::test]
async fn an_account_over_its_validation_cap_is_rate_limited() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, bypassing().with_max_in_flight_per_account(1));
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (first_order, mut first) = pending_order(&database, &account, &["a.example.com"]).await;
let (second_order, mut second) =
pending_order(&database, &account, &["b.example.com"]).await;
let (first_authz, first_challenge) = &mut first[0];
let (second_authz, second_challenge) = &mut second[0];
assert_eq!(
orders
.claim_challenge(first_challenge, first_authz, &first_order)
.await
.unwrap(),
ValidationClaim::Claimed
);
assert_eq!(
orders
.claim_challenge(second_challenge, second_authz, &second_order)
.await
.unwrap(),
ValidationClaim::Limited
);
assert_eq!(second_challenge.status, ChallengeStatus::Pending);
let mut order = reload(&database, &first_order).await;
orders
.run_validation(&account, first_challenge, first_authz, &mut order, None)
.await
.unwrap();
assert_eq!(
orders
.claim_challenge(second_challenge, second_authz, &second_order)
.await
.unwrap(),
ValidationClaim::Claimed
);
}
#[tokio::test]
async fn a_trigger_under_an_invalid_order_is_refused() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, refusing());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (mut order, mut authzs) =
pending_order(&database, &account, &["a.example.com", "b.example.com"]).await;
let (first, rest) = authzs.split_at_mut(1);
let (authz_a, challenge_a) = &mut first[0];
let (authz_b, challenge_b) = &mut rest[0];
assert_eq!(
orders
.claim_challenge(challenge_a, authz_a, &order)
.await
.unwrap(),
ValidationClaim::Claimed
);
orders
.run_validation(&account, challenge_a, authz_a, &mut order, None)
.await
.unwrap();
assert_eq!(order.status, OrderStatus::Invalid);
let refused = orders
.claim_challenge(challenge_b, authz_b, &order)
.await
.unwrap_err();
assert_eq!(Problem::from(refused).status(), 400);
assert_eq!(
challenge_b
.claim_for_validation(0, &database)
.await
.unwrap(),
ValidationClaim::Decided
);
}
#[tokio::test]
async fn a_trigger_that_straddles_its_own_verdict_is_answered_with_the_challenge() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, refusing());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (mut order, mut authzs) = pending_order(&database, &account, &["a.example.com"]).await;
let (authz, challenge) = &mut authzs[0];
assert_eq!(
orders
.claim_challenge(challenge, authz, &order)
.await
.unwrap(),
ValidationClaim::Claimed
);
let mut stale = Challenge::find_by_id(challenge.id.to_string().as_str(), &database)
.await
.unwrap()
.unwrap();
assert_eq!(stale.status, ChallengeStatus::Processing);
orders
.run_validation(&account, challenge, authz, &mut order, None)
.await
.unwrap();
assert_eq!(authz.status, AuthzStatus::Invalid);
assert_eq!(order.status, OrderStatus::Invalid);
assert_eq!(
orders
.claim_challenge(&mut stale, authz, &order)
.await
.unwrap(),
ValidationClaim::Decided
);
assert_eq!(stale.status, ChallengeStatus::Invalid);
assert!(
stale.error.is_some(),
"the refreshed challenge carries the verdict the client came for"
);
}
#[tokio::test]
async fn a_replaces_collision_is_told_apart_from_other_unique_violations() {
let database = Database::connect_in_memory().await.unwrap();
sqlx::query(
"INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
VALUES ('acct', 'default', X'00', '[]', 'valid', 0);",
)
.execute(database.raw_pool())
.await
.unwrap();
let order = |id: &'static str, replaces: &'static str| {
let pool = database.raw_pool().clone();
async move {
sqlx::query(
"INSERT INTO orders (id, profile, account_id, status, identifiers, expires, \
replaces, created_at) VALUES (?, 'default', 'acct', 'pending', '[]', 0, ?, 0);",
)
.bind(id)
.bind(replaces)
.execute(&pool)
.await
}
};
order("first", "predecessor-cert-id").await.unwrap();
let collision = order("second", "predecessor-cert-id").await.unwrap_err();
assert!(is_replaces_conflict(&collision), "got {collision}");
let authz = |id: &'static str| {
let pool = database.raw_pool().clone();
async move {
sqlx::query(
"INSERT INTO authorizations (id, order_id, identifier, status, expires, \
created_at) VALUES (?, 'first', '{\"type\":\"dns\",\"value\":\"a.example.com\"}', \
'pending', 0, 0);",
)
.bind(id)
.execute(&pool)
.await
}
};
authz("authz-one").await.unwrap();
let other = authz("authz-two").await.unwrap_err();
assert!(
!is_replaces_conflict(&other),
"an authorization collision must not read as alreadyReplaced: {other}"
);
let missing = sqlx::query("INSERT INTO orders (id) VALUES ('x');")
.execute(database.raw_pool())
.await
.unwrap_err();
assert!(!is_replaces_conflict(&missing));
}
pub(crate) async fn ready_order(
database: &Arc<Database>,
account: &Account,
) -> (Order, String) {
let (order, authzs) = pending_order(database, account, &["a.example.com"]).await;
for (authz, _) in &authzs {
assert!(
Authorization::set_valid(authz.id, database.raw_pool())
.await
.unwrap()
);
}
assert!(
Order::set_ready(order.id, database.raw_pool())
.await
.unwrap()
);
let key = rcgen::KeyPair::generate().unwrap();
let csr = rcgen::CertificateParams::new(vec!["a.example.com".to_string()])
.unwrap()
.serialize_request(&key)
.unwrap();
(
reload(database, &order).await,
BASE64_URL_SAFE_NO_PAD.encode(csr.der()),
)
}
async fn finalize_ready() -> (Arc<Database>, Order, Result<Order, Error>) {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, ChallengeRegistry::default());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (order, csr) = ready_order(&database, &account).await;
let before = reload(&database, &order).await;
let jobs = acme_proxy_jobs::testutil::idle_job_queue(database.clone());
let outcome = orders
.finalize(
&account,
order,
&csr,
None,
&RequestContext::default(),
&jobs,
)
.await;
(database, before, outcome)
}
#[tokio::test]
async fn finalize_claims_the_order_and_queues_its_issuance() {
let (database, order, outcome) = finalize_ready().await;
let answered = outcome.unwrap();
assert_eq!(answered.status, OrderStatus::Processing);
let stored = reload(&database, &order).await;
assert_eq!(stored.status, OrderStatus::Processing);
assert!(stored.certificate.is_none(), "nothing was signed here");
let job = acme_proxy_store::job::Job::find_live(
super::super::issue::SIGNER_ISSUE_KIND,
&order.id.to_string(),
&database,
)
.await
.unwrap()
.expect("the issuance is queued");
assert_eq!(job.payload["order_id"], order.id.to_string());
assert_eq!(job.payload["profile"], "default");
assert!(
job.payload["csr"]
.as_str()
.is_some_and(|csr| !csr.is_empty())
);
assert_eq!(job.deadline, Some(order.expires));
}
#[tokio::test]
async fn a_second_finalize_loses_the_claim() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, ChallengeRegistry::default());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (order, csr) = ready_order(&database, &account).await;
let jobs = acme_proxy_jobs::testutil::idle_job_queue(database.clone());
let rival = reload(&database, &order).await;
orders
.finalize(
&account,
order,
&csr,
None,
&RequestContext::default(),
&jobs,
)
.await
.unwrap();
let error = orders
.finalize(
&account,
rival,
&csr,
None,
&RequestContext::default(),
&jobs,
)
.await
.unwrap_err();
let problem = Problem::from(error).to_value();
assert_eq!(problem["type"], "urn:ietf:params:acme:error:orderNotReady");
assert_eq!(problem["detail"], "Order is already being finalized");
}
#[tokio::test]
async fn a_failed_enqueue_leaves_the_order_ready() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let profile = profile(&database, ChallengeRegistry::default());
let audit = Auditor::offline(database.clone());
let orders = OrderService {
database: &database,
audit: &audit,
profile: &profile,
};
let account = account(&database).await;
let (order, csr) = ready_order(&database, &account).await;
let jobs = acme_proxy_jobs::testutil::idle_job_queue(database.clone());
sqlx::query("DROP TABLE jobs;")
.execute(database.raw_pool())
.await
.unwrap();
let before = reload(&database, &order).await;
let error = orders
.finalize(
&account,
order,
&csr,
None,
&RequestContext::default(),
&jobs,
)
.await
.unwrap_err();
assert_eq!(
Problem::from(error).to_value()["detail"],
"Order finalize failed"
);
assert_eq!(reload(&database, &before).await.status, OrderStatus::Ready);
}
}