use axum::{
Json,
extract::{Path, State},
http::{HeaderValue, header},
response::{IntoResponse, Response},
};
use serde_json::{Value, json};
use tracing::{debug, error, info, instrument, warn};
use crate::router::AppState;
use acme_proxy_core::error::Problem;
use acme_proxy_signer::RenewalWindow;
use acme_proxy_store::nonce::now_secs;
use acme_proxy_store::order::Order;
const ONE_DAY_SECONDS: i64 = 86_400;
const DEFAULT_RETRY_AFTER: &str = "21600";
const REVOKED_RETRY_AFTER: &str = "60";
#[must_use]
pub fn calculate_suggested_window(
not_before: i64,
not_after: i64,
is_revoked: bool,
now: i64,
) -> RenewalWindow {
if is_revoked {
return RenewalWindow::new(now - ONE_DAY_SECONDS, now);
}
let duration = not_after - not_before;
RenewalWindow::new(
not_before + (duration * 2 / 3),
not_before + (duration * 3 / 4),
)
}
#[instrument(name = "get_renewal_info", skip_all, fields(cert_id = %id))]
pub async fn get_renewal_info(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Response, Problem> {
info!(
event = "renewal_info_requested",
outcome = "progress",
cert_id = %id
);
let cert_id = acme_proxy_core::cert::parse_ari_cert_id(&id).map_err(|error| {
warn!(event = "renewal_info_invalid_id_format", outcome = "failure", cert_id = %id, error = %error);
Problem::malformed(format!("Invalid certID: {error}"))
})?;
let serial_hex = cert_id.serial_hex();
let order = Order::find_by_cert_serial(&state.profile.name, &serial_hex, &state.database)
.await
.map_err(|error| {
warn!(event = "renewal_info_lookup_error", outcome = "failure", error = %error);
Problem::server_internal("Database error looking up certificate")
})?
.ok_or_else(|| {
warn!(event = "renewal_info_not_found", outcome = "failure", cert_serial = %serial_hex);
Problem::malformed("Unknown certificate")
})?;
let certificate_pem = order.certificate.as_ref().ok_or_else(|| {
debug!(event = "renewal_info_no_certificate", outcome = "failure", order_id = %order.id);
Problem::malformed("Order does not have a certificate")
})?;
let leaf_der =
acme_proxy_core::cert::leaf_der_from_chain(certificate_pem).map_err(|error| {
error!(event = "renewal_info_cert_parse_failed", outcome = "failure", error = %error);
Problem::server_internal("Stored certificate is unparsable")
})?;
match acme_proxy_core::cert::ari_cert_id_parts(&leaf_der) {
Ok((aki, _serial)) if aki != cert_id.aki => {
warn!(event = "renewal_info_aki_mismatch", outcome = "failure", cert_id = %id, cert_serial = %serial_hex);
return Err(Problem::malformed(
"certID key identifier does not match the certificate",
));
}
Ok(_) => {}
Err(error) => {
debug!(
event = "renewal_info_aki_unavailable",
outcome = "advisory",
cert_serial = %serial_hex,
error = %error,
"certificate carries no Authority Key Identifier; matching on serial alone"
);
}
}
let (not_before, not_after) = acme_proxy_core::cert::cert_validity(&leaf_der).map_err(|error| {
error!(event = "renewal_info_cert_validity_parse_failed", outcome = "failure", error = %error);
Problem::server_internal("Failed to parse certificate validity")
})?;
let now = now_secs();
let revoked = order.revoked_at.is_some();
let window = if revoked {
calculate_suggested_window(not_before, not_after, true, now)
} else {
match state.profile.signer_info.renewal_info(&leaf_der).await {
Ok(Some(window)) => window,
Ok(None) => calculate_suggested_window(not_before, not_after, false, now),
Err(error) => {
warn!(event = "renewal_info_upstream_failed", outcome = "failure", error = %error);
calculate_suggested_window(not_before, not_after, false, now)
}
}
};
if window.end <= window.start {
error!(
event = "renewal_info_invalid_window",
outcome = "failure",
cert_serial = %serial_hex,
start = window.start,
end = window.end,
);
return Err(Problem::server_internal(
"Computed renewal window is invalid",
));
}
let mut suggested = serde_json::Map::new();
suggested.insert(
"start".to_string(),
json!(acme_proxy_core::datetime::rfc3339(window.start)),
);
suggested.insert(
"end".to_string(),
json!(acme_proxy_core::datetime::rfc3339(window.end)),
);
let mut body = serde_json::Map::new();
body.insert("suggestedWindow".to_string(), Value::Object(suggested));
if let Some(url) = window.explanation_url {
body.insert("explanationURL".to_string(), Value::String(url));
}
let mut response = Json(Value::Object(body)).into_response();
let retry_after = if revoked {
REVOKED_RETRY_AFTER
} else {
DEFAULT_RETRY_AFTER
};
response
.headers_mut()
.insert(header::RETRY_AFTER, HeaderValue::from_static(retry_after));
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_suggested_window_covers_the_last_third_of_the_validity() {
let window = calculate_suggested_window(1000, 4000, false, 5000);
assert_eq!(window.start, 1000 + (3000 * 2 / 3));
assert_eq!(window.end, 1000 + (3000 * 3 / 4));
assert!(window.start < window.end, "the window must be non-empty");
assert!(
window.explanation_url.is_none(),
"a locally computed window has nothing to explain"
);
}
#[test]
fn a_revoked_certificate_gets_a_window_entirely_in_the_past() {
let now = 2000;
let window = calculate_suggested_window(1000, 4000, true, now);
assert_eq!(window.start, now - ONE_DAY_SECONDS);
assert_eq!(window.end, now);
assert!(
window.end <= now,
"a revoked certificate must be replaced immediately"
);
}
#[test]
fn revocation_overrides_the_validity_based_window() {
let now = 2_000_000;
let fresh = calculate_suggested_window(1_000_000, 9_000_000, false, now);
let revoked = calculate_suggested_window(1_000_000, 9_000_000, true, now);
assert!(
fresh.start > now,
"without revocation, the window is in the future"
);
assert!(revoked.start < now && revoked.end <= now);
}
}