use std::sync::Arc;
use acme_proxy_store::db::Database;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use base64::prelude::*;
use serde_json::{Value, json};
use tower::ServiceExt;
mod common;
use common::{
EcSigner, TestSigner, body_json, fetch_nonce, first_certificate, make_csr, make_csr_for, p,
test_app, test_app_with_db,
};
const NEW_ACCOUNT_URL: &str = "http://localhost:3000/profile/default/newAccount";
const NEW_ORDER_URL: &str = "http://localhost:3000/profile/default/newOrder";
async fn post(app: &Router, path: &str, body: String) -> Response {
app.clone()
.oneshot(
Request::post(path)
.header("content-type", "application/jose+json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
async fn register(app: &Router, signer: &impl TestSigner) -> String {
let nonce = fetch_nonce(app).await;
let payload = json!({ "termsOfServiceAgreed": true });
let res = post(
app,
&p("/newAccount"),
signer.sign(NEW_ACCOUNT_URL, &nonce, &payload),
)
.await;
assert_eq!(res.status(), StatusCode::CREATED);
res.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.expect("newAccount must set a Location header")
.to_string()
}
async fn new_order(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
dns: &str,
) -> Response {
let nonce = fetch_nonce(app).await;
let payload = json!({ "identifiers": [{ "type": "dns", "value": dns }] });
let body = signer.sign_kid(account_url, NEW_ORDER_URL, &nonce, &payload);
post(app, &p("/newOrder"), body).await
}
async fn post_as_get(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
url: &str,
) -> Value {
let path = url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid_empty(account_url, url, &nonce);
let res = post(app, path, body).await;
assert_eq!(res.status(), StatusCode::OK);
body_json(res).await
}
async fn ready_order(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
dns: &str,
) -> String {
let res = new_order(app, signer, account_url, dns).await;
assert_eq!(res.status(), StatusCode::CREATED);
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = post_as_get(app, signer, account_url, &order_url).await;
for authz_url in order["authorizations"].as_array().unwrap() {
let authz_url = authz_url.as_str().unwrap();
let authz = post_as_get(app, signer, account_url, authz_url).await;
for challenge in authz["challenges"].as_array().unwrap() {
let chall_url = challenge["url"].as_str().unwrap();
let path = chall_url.strip_prefix(common::HOST).unwrap();
for _ in 0..600 {
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid(account_url, chall_url, &nonce, &json!({}));
let res = post(app, path, body).await;
assert_eq!(res.status(), StatusCode::OK);
if body_json(res).await["status"] != "processing" {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
order_url
}
async fn finalize(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order_url: &str,
csr: &str,
) -> Response {
let url = format!("{order_url}/finalize");
let path = url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid(account_url, &url, &nonce, &json!({ "csr": csr }));
post(app, path, body).await
}
#[tokio::test]
async fn a_deactivated_account_cannot_create_an_order() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let path = account_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(
&account_url,
&account_url,
&nonce,
&json!({ "status": "deactivated" }),
);
assert_eq!(post(&app, path, body).await.status(), StatusCode::OK);
let res = new_order(&app, &signer, &account_url, "example.com").await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:unauthorized");
}
#[tokio::test]
async fn a_deactivated_account_cannot_finalize_a_ready_order() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order_url = ready_order(&app, &signer, &account_url, "example.com").await;
let path = account_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(
&account_url,
&account_url,
&nonce,
&json!({ "status": "deactivated" }),
);
assert_eq!(post(&app, path, body).await.status(), StatusCode::OK);
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("example.com"),
)
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:unauthorized"
);
}
#[tokio::test]
async fn a_deactivated_key_is_refused_by_new_account() {
for only_return_existing in [true, false] {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let path = account_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(
&account_url,
&account_url,
&nonce,
&json!({ "status": "deactivated" }),
);
assert_eq!(post(&app, path, body).await.status(), StatusCode::OK);
let nonce = fetch_nonce(&app).await;
let payload = if only_return_existing {
json!({ "onlyReturnExisting": true })
} else {
json!({ "termsOfServiceAgreed": true })
};
let res = post(
&app,
&p("/newAccount"),
signer.sign(NEW_ACCOUNT_URL, &nonce, &payload),
)
.await;
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"onlyReturnExisting={only_return_existing}"
);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:unauthorized");
assert!(problem.get("contact").is_none());
}
}
#[tokio::test]
async fn a_wildcard_identifier_is_rejected_when_dns_01_is_disabled() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, "*.example.com").await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
let problem = body_json(res).await;
assert_eq!(
problem["type"],
"urn:ietf:params:acme:error:rejectedIdentifier"
);
assert!(
problem["detail"].as_str().unwrap().contains("dns-01"),
"{problem}"
);
}
#[tokio::test]
async fn several_bad_identifiers_are_reported_together_as_subproblems() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let nonce = fetch_nonce(&app).await;
let payload = json!({
"identifiers": [
{ "type": "dns", "value": "fine.example.com" },
{ "type": "dns", "value": "*.*.example.com" }, { "type": "dns", "value": "*.example.com" }, ]
});
let res = post(
&app,
&p("/newOrder"),
signer.sign_kid(&account_url, NEW_ORDER_URL, &nonce, &payload),
)
.await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:compound");
assert!(
problem.get("identifier").is_none(),
"§6.7.1 forbids `identifier` at the top level: {problem}"
);
let subproblems = problem["subproblems"].as_array().expect("subproblems");
assert_eq!(subproblems.len(), 2, "only the bad names: {problem}");
let by_value: Vec<(&str, &str)> = subproblems
.iter()
.map(|sub| {
(
sub["identifier"]["value"].as_str().unwrap(),
sub["type"].as_str().unwrap(),
)
})
.collect();
assert!(by_value.contains(&("*.*.example.com", "urn:ietf:params:acme:error:malformed")));
assert!(by_value.contains(&(
"*.example.com",
"urn:ietf:params:acme:error:rejectedIdentifier"
)));
assert!(
!by_value
.iter()
.any(|(value, _)| *value == "fine.example.com")
);
}
#[tokio::test]
async fn a_single_bad_identifier_is_not_wrapped_in_a_compound() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, "*.example.com").await;
let problem = body_json(res).await;
assert_eq!(
problem["type"],
"urn:ietf:params:acme:error:rejectedIdentifier"
);
assert!(problem.get("subproblems").is_none(), "{problem}");
}
#[tokio::test]
async fn a_malformed_wildcard_identifier_is_rejected_as_malformed() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
for value in ["*example.com", "*.*.example.com", "a.*.example.com", "*"] {
let res = new_order(&app, &signer, &account_url, value).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "{value}");
let problem = body_json(res).await;
assert_eq!(
problem["type"], "urn:ietf:params:acme:error:malformed",
"{value}"
);
}
}
#[tokio::test]
async fn an_ip_address_is_not_a_dns_identifier() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
for value in [
"10.0.0.5",
"169.254.169.254",
"2130706433",
"0x7f.1",
"*.10.0.0.5",
] {
let res = new_order(&app, &signer, &account_url, value).await;
assert_eq!(res.status(), StatusCode::FORBIDDEN, "{value}");
let problem = body_json(res).await;
assert_eq!(
problem["type"], "urn:ietf:params:acme:error:rejectedIdentifier",
"{value}"
);
assert!(
problem["detail"].as_str().unwrap().contains("IP address"),
"{problem}"
);
}
let res = new_order(&app, &signer, &account_url, "10.0.0.5.example.com").await;
assert_eq!(res.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn identifiers_are_normalized_before_they_are_stored() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
for spelling in ["EXAMPLE.com.", "Example.COM", "example.com."] {
let res = new_order(&app, &signer, &account_url, spelling).await;
assert_eq!(res.status(), StatusCode::CREATED);
let order = body_json(res).await;
assert_eq!(
order["identifiers"][0]["value"], "example.com",
"{spelling} should normalize to example.com"
);
}
}
#[tokio::test]
async fn an_order_placed_with_a_trailing_dot_finalizes_normally() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order_url = ready_order(&app, &signer, &account_url, "EXAMPLE.com.").await;
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("example.com"),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn an_expired_order_cannot_be_finalized() {
let (app, db) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order_url = ready_order(&app, &signer, &account_url, "example.com").await;
expire_orders(&db).await;
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("example.com"),
)
.await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:malformed");
assert!(
problem["detail"]
.as_str()
.unwrap()
.to_lowercase()
.contains("expired"),
"the problem should say the order expired, got {problem}"
);
}
#[tokio::test]
async fn an_expired_authorization_cannot_be_validated() {
let (app, db) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let res = new_order(&app, &signer, &account_url, "example.com").await;
assert_eq!(res.status(), StatusCode::CREATED);
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = post_as_get(&app, &signer, &account_url, &order_url).await;
let authz_url = order["authorizations"][0].as_str().unwrap().to_string();
let authz = post_as_get(&app, &signer, &account_url, &authz_url).await;
let chall_url = authz["challenges"][0]["url"].as_str().unwrap().to_string();
sqlx::query("UPDATE authorizations SET expires = 1;")
.execute(db.raw_pool())
.await
.unwrap();
let path = chall_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &chall_url, &nonce, &json!({}));
let res = post(&app, path, body).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:malformed");
}
#[tokio::test]
async fn an_issued_order_is_still_readable_after_it_expires() {
let (app, db) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order_url = ready_order(&app, &signer, &account_url, "example.com").await;
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("example.com"),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let order = common::acme::await_order(&app, &signer, &account_url, &order_url).await;
let cert_url = order["certificate"].as_str().unwrap().to_string();
expire_orders(&db).await;
let order = post_as_get(&app, &signer, &account_url, &order_url).await;
assert_eq!(order["status"], "valid");
let path = cert_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &cert_url, &nonce);
assert_eq!(post(&app, path, body).await.status(), StatusCode::OK);
}
async fn expire_orders(db: &Arc<Database>) {
sqlx::query("UPDATE orders SET expires = 1;")
.execute(db.raw_pool())
.await
.unwrap();
}
#[tokio::test]
async fn an_order_missing_an_authorization_never_becomes_ready() {
let (app, db) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let nonce = fetch_nonce(&app).await;
let payload = json!({ "identifiers": [
{ "type": "dns", "value": "a.example.com" },
{ "type": "dns", "value": "b.example.com" },
]});
let body = signer.sign_kid(&account_url, NEW_ORDER_URL, &nonce, &payload);
let res = post(&app, &p("/newOrder"), body).await;
assert_eq!(res.status(), StatusCode::CREATED);
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = post_as_get(&app, &signer, &account_url, &order_url).await;
let authz_urls: Vec<String> = order["authorizations"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(authz_urls.len(), 2);
let surviving: uuid::Uuid = authz_urls[0].rsplit('/').next().unwrap().parse().unwrap();
sqlx::query("DELETE FROM challenges WHERE authz_id != ?;")
.bind(surviving)
.execute(db.raw_pool())
.await
.unwrap();
sqlx::query("DELETE FROM authorizations WHERE id != ?;")
.bind(surviving)
.execute(db.raw_pool())
.await
.unwrap();
let authz = post_as_get(&app, &signer, &account_url, &authz_urls[0]).await;
let chall_url = authz["challenges"][0]["url"].as_str().unwrap().to_string();
let path = chall_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &chall_url, &nonce, &json!({}));
assert_eq!(post(&app, path, body).await.status(), StatusCode::OK);
let order = post_as_get(&app, &signer, &account_url, &order_url).await;
assert_eq!(
order["status"], "pending",
"an order with fewer authorizations than identifiers must not be ready"
);
let csr = make_csr_for(&["a.example.com", "b.example.com"]);
let res = finalize(&app, &signer, &account_url, &order_url, &csr).await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:orderNotReady");
}
#[tokio::test]
async fn a_csr_requesting_ca_powers_yields_a_leaf_without_them() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order_url = ready_order(&app, &signer, &account_url, "example.com").await;
let key_pair = rcgen::KeyPair::generate().unwrap();
let mut params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
params.key_usages = vec![
rcgen::KeyUsagePurpose::KeyCertSign,
rcgen::KeyUsagePurpose::CrlSign,
];
let csr = params.serialize_request(&key_pair).unwrap();
let csr_b64 = BASE64_URL_SAFE_NO_PAD.encode(csr.der());
let res = finalize(&app, &signer, &account_url, &order_url, &csr_b64).await;
assert_eq!(res.status(), StatusCode::OK);
let order = common::acme::await_order(&app, &signer, &account_url, &order_url).await;
let cert_url = order["certificate"].as_str().unwrap().to_string();
let path = cert_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &cert_url, &nonce);
let res = post(&app, path, body).await;
assert_eq!(res.status(), StatusCode::OK);
let pem = String::from_utf8(
http_body_util::BodyExt::collect(res.into_body())
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
let leaf_der = first_certificate(&pem);
let (_, parsed) = x509_parser::parse_x509_certificate(&leaf_der).unwrap();
assert!(
parsed
.basic_constraints()
.unwrap()
.is_none_or(|bc| !bc.value.ca),
"the issued leaf must not be a CA"
);
assert!(
!parsed.key_usage().unwrap().unwrap().value.key_cert_sign(),
"the issued leaf must not be able to sign certificates"
);
}
#[tokio::test]
async fn an_order_naming_more_identifiers_than_the_limit_is_refused() {
async fn order_naming(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
names: &[String],
) -> Response {
let identifiers: Vec<Value> = names
.iter()
.map(|name| json!({ "type": "dns", "value": name }))
.collect();
let nonce = fetch_nonce(app).await;
let payload = json!({ "identifiers": identifiers });
let body = signer.sign_kid(account_url, NEW_ORDER_URL, &nonce, &payload);
post(app, &p("/newOrder"), body).await
}
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let limit = acme_proxy_core::config::Config::default()
.order
.max_identifiers;
let names: Vec<String> = (0..=limit).map(|n| format!("h{n}.example.com")).collect();
let res = order_naming(&app, &signer, &account_url, &names).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:malformed");
assert!(
problem["detail"]
.as_str()
.unwrap_or_default()
.contains(&limit.to_string()),
"the refusal must say what the limit is: {problem}"
);
let res = order_naming(&app, &signer, &account_url, &names[..limit]).await;
assert_eq!(res.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn a_panicking_acme_handler_answers_a_problem_document() {
use axum::routing::get;
async fn boom() -> &'static str {
panic!("boom")
}
let app = Router::new()
.route("/boom", get(boom))
.layer(acme_proxy_protocol::router::catch_panic_acme());
let res = app
.oneshot(Request::get("/boom").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
res.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/problem+json"),
);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:serverInternal");
}
#[tokio::test]
async fn a_panicking_admin_handler_answers_in_the_right_shape_per_surface() {
use axum::body::to_bytes;
use axum::routing::get;
async fn boom() -> &'static str {
panic!("boom")
}
let api = Router::new()
.route("/boom", get(boom))
.layer(acme_proxy_admin::webadmin::catch_panic_admin_api());
let res = api
.oneshot(Request::get("/boom").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
res.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/json"),
);
let body = body_json(res).await;
assert_eq!(body["error"], "internal");
let pages = Router::new()
.route("/boom", get(boom))
.layer(acme_proxy_admin::webadmin::catch_panic_admin_pages());
let res = pages
.oneshot(Request::get("/boom").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = to_bytes(res.into_body(), 64 * 1024).await.unwrap();
assert!(
String::from_utf8(bytes.to_vec())
.unwrap()
.starts_with("<!doctype html>"),
);
}