use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tower::ServiceExt;
mod common;
use base64::prelude::*;
use common::{
EcSigner, FailingSigner, GarbageChainSigner, PREFIX, RsaSigner, TestSigner, acme, body_json,
fetch_nonce, make_csr, make_csr_with_sans, p, test_app, test_app_with_signer,
};
const BASE: &str = common::BASE;
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 body_text(response: Response) -> String {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).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 ready_order(app: &Router, signer: &impl TestSigner) -> (String, String) {
let account_url = register(app, signer).await;
let nonce = fetch_nonce(app).await;
let payload = json!({ "identifiers": [{ "type": "dns", "value": "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())
.expect("newOrder must set a Location header")
.to_string();
let authz_url = body_json(res).await["authorizations"][0]
.as_str()
.unwrap()
.to_string();
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid_empty(&account_url, &authz_url, &nonce);
let res = post(app, authz_url.strip_prefix(common::HOST).unwrap(), body).await;
let challenge_url = body_json(res).await["challenges"][0]["url"]
.as_str()
.unwrap()
.to_string();
acme::await_challenge(app, signer, &account_url, &challenge_url).await;
(account_url, order_url)
}
async fn full_lifecycle(signer: impl TestSigner) {
let app = test_app().await;
let account_url = register(&app, &signer).await;
let nonce = fetch_nonce(&app).await;
let payload = json!({ "identifiers": [{ "type": "dns", "value": "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())
.expect("newOrder must set a Location header")
.to_string();
let order = body_json(res).await;
assert_eq!(order["status"], "pending");
let authz_url = order["authorizations"][0]
.as_str()
.expect("order must list an authorization URL")
.to_string();
assert_eq!(order["finalize"], format!("{order_url}/finalize"));
assert_eq!(order["identifiers"][0]["value"], "example.com");
let order_path = order_url.strip_prefix(common::HOST).unwrap();
let authz_path = authz_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &authz_url, &nonce);
let res = post(&app, authz_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
let authz = body_json(res).await;
assert_eq!(authz["status"], "pending");
assert_eq!(
authz["identifier"],
json!({ "type": "dns", "value": "example.com" })
);
let challenge = &authz["challenges"][0];
assert_eq!(challenge["type"], "http-01");
assert_eq!(challenge["status"], "pending");
assert!(challenge["token"].as_str().is_some_and(|t| !t.is_empty()));
let challenge_url = challenge["url"]
.as_str()
.expect("challenge must have a URL")
.to_string();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &challenge_url, &nonce, &json!({}));
let res = post(
&app,
challenge_url.strip_prefix(common::HOST).unwrap(),
body,
)
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_json(res).await["status"], "processing");
assert_eq!(
acme::await_challenge(&app, &signer, &account_url, &challenge_url).await["status"],
"valid"
);
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &order_url, &nonce);
let res = post(&app, order_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_json(res).await["status"], "ready");
let finalize_url = format!("{order_url}/finalize");
let finalize_path = finalize_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let payload = json!({ "csr": make_csr("example.com") });
let body = signer.sign_kid(&account_url, &finalize_url, &nonce, &payload);
let res = post(&app, finalize_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_json(res).await["status"], "processing");
let order = await_order_status(&app, &signer, &account_url, &order_url, "valid").await;
let cert_url = order["certificate"]
.as_str()
.expect("certificate URL")
.to_string();
assert_eq!(
cert_url,
format!(
"{BASE}/certificate/{}",
order_path.strip_prefix(&p("/order/")).unwrap()
)
);
let cert_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, cert_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/pem-certificate-chain"),
);
let pem = body_text(res).await;
assert_eq!(
pem.matches("-----BEGIN CERTIFICATE-----").count(),
2,
"chain should be leaf + CA"
);
let orders_url = format!("{account_url}/orders");
let orders_path = orders_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &orders_url, &nonce);
let res = post(&app, orders_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
let list = body_json(res).await;
assert_eq!(list["orders"][0], order_url);
}
#[tokio::test]
async fn full_lifecycle_ec() {
full_lifecycle(EcSigner::new()).await;
}
#[tokio::test]
async fn full_lifecycle_rsa() {
full_lifecycle(RsaSigner::new()).await;
}
async fn setup_order() -> (Router, EcSigner, String, String) {
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": "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();
(app, signer, account_url, order_url)
}
async fn order_authz_urls(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order_url: &str,
) -> Vec<String> {
let order_path = order_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid_empty(account_url, order_url, &nonce);
let res = post(app, order_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
body_json(res).await["authorizations"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
}
async fn first_challenge_url(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
authz_url: &str,
) -> String {
let authz_path = authz_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid_empty(account_url, authz_url, &nonce);
let res = post(app, authz_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
body_json(res).await["challenges"][0]["url"]
.as_str()
.unwrap()
.to_string()
}
async fn trigger_challenge(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
challenge_url: &str,
) -> Response {
let challenge_path = challenge_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid(account_url, challenge_url, &nonce, &json!({}));
post(app, challenge_path, body).await
}
async fn await_order_status(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order_url: &str,
status: &str,
) -> Value {
for _ in 0..600 {
let order = post_as_get(app, signer, account_url, order_url).await;
if order["status"] == status {
return order;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!("`{order_url}` never reached `{status}`");
}
async fn drive_order_to_ready(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order_url: &str,
) {
for authz_url in order_authz_urls(app, signer, account_url, order_url).await {
let challenge_url = first_challenge_url(app, signer, account_url, &authz_url).await;
let res = trigger_challenge(app, signer, account_url, &challenge_url).await;
assert_eq!(res.status(), StatusCode::OK);
acme::await_challenge(app, signer, account_url, &challenge_url).await;
}
}
async fn setup_ready_order() -> (Router, EcSigner, String, String) {
let (app, signer, account_url, order_url) = setup_order().await;
drive_order_to_ready(&app, &signer, &account_url, &order_url).await;
(app, signer, account_url, order_url)
}
async fn setup_ready_order_with_signer(
backend: Arc<dyn acme_proxy_signer::SignerBackend>,
) -> (Router, EcSigner, String, String) {
let (app, _db) = test_app_with_signer(backend).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": "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();
drive_order_to_ready(&app, &signer, &account_url, &order_url).await;
(app, signer, account_url, order_url)
}
async fn assert_problem(res: Response, status: StatusCode, typ: &str) {
assert_eq!(res.status(), status);
let problem = body_json(res).await;
assert_eq!(problem["type"], typ);
}
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 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 new_order_rejects_wrong_url() {
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": "example.com" }] });
let body = signer.sign_kid(&account_url, &format!("{BASE}/wrong"), &nonce, &payload);
let res = post(&app, &p("/newOrder"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn new_order_rejects_bad_nonce() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let payload = json!({ "identifiers": [{ "type": "dns", "value": "example.com" }] });
let body = signer.sign_kid(&account_url, NEW_ORDER_URL, "not-a-real-nonce", &payload);
let res = post(&app, &p("/newOrder"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badNonce",
)
.await;
}
#[tokio::test]
async fn new_order_rejects_empty_identifiers() {
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": [] });
let body = signer.sign_kid(&account_url, NEW_ORDER_URL, &nonce, &payload);
let res = post(&app, &p("/newOrder"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn new_order_rejects_non_dns_identifier() {
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": "ip", "value": "192.0.2.1" }] });
let body = signer.sign_kid(&account_url, NEW_ORDER_URL, &nonce, &payload);
let res = post(&app, &p("/newOrder"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:unsupportedIdentifier",
)
.await;
}
#[tokio::test]
async fn finalize_rejects_non_ready_order() {
let (app, signer, account_url, order_url) = setup_order().await;
let finalize_url = format!("{order_url}/finalize");
let finalize_path = finalize_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let payload = json!({ "csr": make_csr("example.com") });
let body = signer.sign_kid(&account_url, &finalize_url, &nonce, &payload);
let res = post(&app, finalize_path, body).await;
assert_problem(
res,
StatusCode::FORBIDDEN,
"urn:ietf:params:acme:error:orderNotReady",
)
.await;
}
#[tokio::test]
async fn finalize_rejects_ready_order_reused() {
let (app, signer, account_url, order_url) = setup_ready_order().await;
let finalize_url = format!("{order_url}/finalize");
let finalize_path = finalize_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let payload = json!({ "csr": make_csr("example.com") });
let body = signer.sign_kid(&account_url, &finalize_url, &nonce, &payload);
assert_eq!(
post(&app, finalize_path, body).await.status(),
StatusCode::OK
);
let nonce = fetch_nonce(&app).await;
let payload = json!({ "csr": make_csr("example.com") });
let body = signer.sign_kid(&account_url, &finalize_url, &nonce, &payload);
let res = post(&app, finalize_path, body).await;
assert_problem(
res,
StatusCode::FORBIDDEN,
"urn:ietf:params:acme:error:orderNotReady",
)
.await;
}
#[tokio::test]
async fn finalize_rejects_mismatched_csr() {
let (app, signer, account_url, order_url) = setup_ready_order().await;
let finalize_url = format!("{order_url}/finalize");
let finalize_path = finalize_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let payload = json!({ "csr": make_csr("attacker.example") });
let body = signer.sign_kid(&account_url, &finalize_url, &nonce, &payload);
let res = post(&app, finalize_path, body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badCSR",
)
.await;
}
#[tokio::test]
async fn post_as_get_rejects_different_account() {
let (app, _owner, _owner_url, order_url) = setup_order().await;
let order_path = order_url.strip_prefix(common::HOST).unwrap();
let intruder = EcSigner::new();
let intruder_url = register(&app, &intruder).await;
let nonce = fetch_nonce(&app).await;
let body = intruder.sign_kid_empty(&intruder_url, &order_url, &nonce);
let res = post(&app, order_path, body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn post_as_get_rejects_non_empty_payload() {
let (app, signer, account_url, order_url) = setup_order().await;
let order_path = order_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let payload = json!({ "not": "empty" });
let body = signer.sign_kid(&account_url, &order_url, &nonce, &payload);
let res = post(&app, order_path, body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn finalize_internal_signer_failure_marks_order_invalid() {
let (app, signer, account_url, order_url) =
setup_ready_order_with_signer(Arc::new(FailingSigner)).await;
let finalize_url = format!("{order_url}/finalize");
let finalize_path = finalize_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let payload = json!({ "csr": make_csr("example.com") });
let body = signer.sign_kid(&account_url, &finalize_url, &nonce, &payload);
let res = post(&app, finalize_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_json(res).await["status"], "processing");
let order = await_order_status(&app, &signer, &account_url, &order_url, "invalid").await;
assert_eq!(
order["error"]["type"],
"urn:ietf:params:acme:error:serverInternal"
);
assert_eq!(order["error"]["status"], 500);
}
#[tokio::test]
async fn two_concurrent_finalizes_issue_exactly_one_certificate() {
let backend = Arc::new(common::GatedSigner::new().await);
let (calls, gate, entered) = backend.handles();
let (app, signer, account_url, order_url) = setup_ready_order_with_signer(backend).await;
let finalize_url = format!("{order_url}/finalize");
let finalize_path = finalize_url.strip_prefix(common::HOST).unwrap().to_string();
let nonce_a = fetch_nonce(&app).await;
let nonce_b = fetch_nonce(&app).await;
let body_a = signer.sign_kid(
&account_url,
&finalize_url,
&nonce_a,
&json!({ "csr": make_csr("example.com") }),
);
let app_a = app.clone();
let path_a = finalize_path.clone();
let task_a = tokio::spawn(async move { post(&app_a, &path_a, body_a).await });
let _ = entered.acquire().await.unwrap();
let body_b = signer.sign_kid(
&account_url,
&finalize_url,
&nonce_b,
&json!({ "csr": make_csr("example.com") }),
);
let res_b = post(&app, &finalize_path, body_b).await;
assert_problem(
res_b,
StatusCode::FORBIDDEN,
"urn:ietf:params:acme:error:orderNotReady",
)
.await;
gate.add_permits(1);
let res_a = task_a.await.unwrap();
assert_eq!(res_a.status(), StatusCode::OK);
let order = await_order_status(&app, &signer, &account_url, &order_url, "valid").await;
assert!(order["certificate"].as_str().is_some());
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"a second certificate was signed"
);
}
#[tokio::test]
async fn post_as_get_unknown_order_is_malformed() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let order_url = format!("{BASE}/order/does-not-exist");
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &order_url, &nonce);
let res = post(&app, &p("/order/does-not-exist"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn post_as_get_unknown_authz_is_malformed() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let authz_url = format!("{BASE}/authz/does-not-exist");
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &authz_url, &nonce);
let res = post(&app, &p("/authz/does-not-exist"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn post_as_get_authz_returns_authorization_object() {
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": "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 = body_json(res).await;
let authz_url = order["authorizations"][0].as_str().unwrap();
let path = authz_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, authz_url, &nonce);
let res = post(&app, path, body).await;
assert_eq!(res.status(), StatusCode::OK);
let authz = body_json(res).await;
assert_eq!(authz["status"], "pending");
assert_eq!(authz["identifier"]["value"], "example.com");
assert!(authz["challenges"].is_array());
}
#[tokio::test]
async fn trigger_unknown_challenge_is_malformed() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
let challenge_url = format!("{BASE}/chall/does-not-exist");
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &challenge_url, &nonce, &json!({}));
let res = post(&app, &p("/chall/does-not-exist"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn authz_rejects_different_account() {
let (app, owner, owner_url, order_url) = setup_order().await;
let authz_url = order_authz_urls(&app, &owner, &owner_url, &order_url)
.await
.remove(0);
let authz_path = authz_url.strip_prefix(common::HOST).unwrap();
let intruder = EcSigner::new();
let intruder_url = register(&app, &intruder).await;
let nonce = fetch_nonce(&app).await;
let body = intruder.sign_kid_empty(&intruder_url, &authz_url, &nonce);
let res = post(&app, authz_path, body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn challenge_trigger_rejects_different_account() {
let (app, owner, owner_url, order_url) = setup_order().await;
let authz_url = order_authz_urls(&app, &owner, &owner_url, &order_url)
.await
.remove(0);
let challenge_url = first_challenge_url(&app, &owner, &owner_url, &authz_url).await;
let intruder = EcSigner::new();
let intruder_url = register(&app, &intruder).await;
let res = trigger_challenge(&app, &intruder, &intruder_url, &challenge_url).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn multi_identifier_order_becomes_ready_after_all_challenges() {
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": "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_path = order_url.strip_prefix(common::HOST).unwrap();
let authz_urls = order_authz_urls(&app, &signer, &account_url, &order_url).await;
assert_eq!(authz_urls.len(), 2, "one authorization per identifier");
let challenge_url = first_challenge_url(&app, &signer, &account_url, &authz_urls[0]).await;
let res = trigger_challenge(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(res.status(), StatusCode::OK);
acme::await_challenge(&app, &signer, &account_url, &challenge_url).await;
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &order_url, &nonce);
let res = post(&app, order_path, body).await;
assert_eq!(body_json(res).await["status"], "pending");
let challenge_url = first_challenge_url(&app, &signer, &account_url, &authz_urls[1]).await;
let res = trigger_challenge(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(res.status(), StatusCode::OK);
acme::await_challenge(&app, &signer, &account_url, &challenge_url).await;
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &order_url, &nonce);
let res = post(&app, order_path, body).await;
assert_eq!(body_json(res).await["status"], "ready");
let res = trigger_challenge(&app, &signer, &account_url, &challenge_url).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_json(res).await["status"], "valid");
}
#[tokio::test]
async fn new_order_accepts_and_echoes_requested_validity_bounds() {
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": "example.com" }],
"notBefore": "2026-01-01T00:00:00Z",
"notAfter": "2026-04-01T00:00:00Z",
});
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 = body_json(res).await;
assert_eq!(order["notBefore"], "2026-01-01T00:00:00Z");
assert_eq!(order["notAfter"], "2026-04-01T00:00:00Z");
}
#[tokio::test]
async fn new_order_rejects_an_unparsable_datetime() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer).await;
for field in ["notBefore", "notAfter"] {
let nonce = fetch_nonce(&app).await;
let payload = json!({
"identifiers": [{ "type": "dns", "value": "example.com" }],
field: "next tuesday",
});
let body = signer.sign_kid(&account_url, NEW_ORDER_URL, &nonce, &payload);
let res = post(&app, &p("/newOrder"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
}
#[tokio::test]
async fn finalize_rejects_a_csr_that_is_not_base64url() {
let (app, signer, account_url, order_url) = setup_ready_order().await;
let res = finalize(&app, &signer, &account_url, &order_url, "!!!not base64!!!").await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badCSR",
)
.await;
let order = post_as_get(&app, &signer, &account_url, &order_url).await;
assert_eq!(order["status"], "ready");
}
#[tokio::test]
async fn an_unparsable_issued_chain_invalidates_the_order() {
let (app, signer, account_url, order_url) =
setup_ready_order_with_signer(Arc::new(GarbageChainSigner)).await;
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("example.com"),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let order = await_order_status(&app, &signer, &account_url, &order_url, "invalid").await;
assert_eq!(
order["error"]["type"],
"urn:ietf:params:acme:error:serverInternal"
);
assert!(order.get("certificate").is_none());
}
#[tokio::test]
async fn a_csr_must_name_exactly_the_order_s_identifiers() {
for csr in [
make_csr("victim.example"),
make_csr_with_sans(
"example.com",
vec![rcgen::SanType::DnsName(
"extra.example.com".try_into().unwrap(),
)],
),
] {
let (app, signer, account_url, order_url) = setup_ready_order().await;
let res = finalize(&app, &signer, &account_url, &order_url, &csr).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badCSR",
)
.await;
}
}
#[tokio::test]
async fn a_mismatched_csr_never_reaches_the_signer_backend() {
let backend = Arc::new(common::RecordingSigner::default());
let (app, signer, account_url, order_url) =
setup_ready_order_with_signer(backend.clone()).await;
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("victim.example"),
)
.await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badCSR",
)
.await;
assert!(
!backend.was_called(),
"the handler must refuse the CSR before any backend sees it"
);
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("example.com"),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
await_order_status(&app, &signer, &account_url, &order_url, "invalid").await;
assert!(backend.was_called());
}
#[tokio::test]
async fn a_csr_smuggling_a_non_dns_san_is_refused() {
let backend = Arc::new(common::RecordingSigner::default());
let (app, signer, account_url, order_url) =
setup_ready_order_with_signer(backend.clone()).await;
let csr = make_csr_with_sans(
"example.com",
vec![rcgen::SanType::IpAddress("10.0.0.1".parse().unwrap())],
);
let res = finalize(&app, &signer, &account_url, &order_url, &csr).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badCSR",
)
.await;
assert!(!backend.was_called());
}
#[tokio::test]
async fn a_csr_whose_common_name_names_another_domain_is_refused() {
let backend = Arc::new(common::RecordingSigner::default());
let (app, signer, account_url, order_url) =
setup_ready_order_with_signer(backend.clone()).await;
let key_pair = rcgen::KeyPair::generate().unwrap();
let mut params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
params
.distinguished_name
.push(rcgen::DnType::CommonName, "victim.example");
let csr = BASE64_URL_SAFE_NO_PAD.encode(params.serialize_request(&key_pair).unwrap().der());
let res = finalize(&app, &signer, &account_url, &order_url, &csr).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badCSR",
)
.await;
assert!(!backend.was_called());
}
#[tokio::test]
async fn the_issued_leaf_carries_no_common_name_from_the_csr() {
let (app, signer, account_url, order_url) = setup_ready_order().await;
let key_pair = rcgen::KeyPair::generate().unwrap();
let mut params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
params
.distinguished_name
.push(rcgen::DnType::CommonName, "some client label");
let csr = BASE64_URL_SAFE_NO_PAD.encode(params.serialize_request(&key_pair).unwrap().der());
let res = finalize(&app, &signer, &account_url, &order_url, &csr).await;
assert_eq!(res.status(), StatusCode::OK);
let order = await_order_status(&app, &signer, &account_url, &order_url, "valid").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 chain = body_text(post(&app, path, body).await).await;
let leaf = acme_proxy_core::cert::leaf_der_from_chain(&chain).unwrap();
let (_, parsed) = x509_parser::parse_x509_certificate(&leaf).unwrap();
assert!(
parsed.subject().iter_common_name().next().is_none(),
"the CSR's common name must not survive into the signed leaf: {}",
parsed.subject()
);
assert!(chain.contains("BEGIN CERTIFICATE"));
}
#[tokio::test]
async fn a_bad_csr_leaves_the_order_finalizable() {
let (app, signer, account_url, order_url) = setup_ready_order().await;
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("wrong.example.com"),
)
.await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:badCSR",
)
.await;
let order = post_as_get(&app, &signer, &account_url, &order_url).await;
assert_eq!(order["status"], "ready");
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("example.com"),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_json(res).await["status"], "processing");
await_order_status(&app, &signer, &account_url, &order_url, "valid").await;
}
#[tokio::test]
async fn the_certificate_is_not_available_before_finalize() {
let (app, signer, account_url, order_url) = setup_ready_order().await;
let id = order_url.rsplit('/').next().unwrap();
let cert_url = format!("{BASE}/certificate/{id}");
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &cert_url, &nonce);
let res = post(&app, &format!("{PREFIX}/certificate/{id}"), body).await;
assert_problem(
res,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}
#[tokio::test]
async fn the_order_list_of_another_account_is_unauthorized() {
let app = test_app().await;
let signer_a = EcSigner::new();
let signer_b = EcSigner::new();
let url_a = register(&app, &signer_a).await;
let url_b = register(&app, &signer_b).await;
let orders_url = format!("{url_b}/orders");
let path = orders_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = signer_a.sign_kid_empty(&url_a, &orders_url, &nonce);
let res = post(&app, path, body).await;
assert_problem(
res,
StatusCode::UNAUTHORIZED,
"urn:ietf:params:acme:error:unauthorized",
)
.await;
}
#[tokio::test]
async fn finalize_with_a_delegating_signer_leaves_the_order_processing() {
let (app, _db) = test_app_with_signer(Arc::new(common::DelegatingSigner)).await;
let signer = EcSigner::new();
let (account_url, order_url) = ready_order(&app, &signer).await;
let order_path = order_url.strip_prefix(common::HOST).unwrap();
let finalize_url = format!("{order_url}/finalize");
let nonce = fetch_nonce(&app).await;
let payload = json!({ "csr": make_csr("example.com") });
let body = signer.sign_kid(&account_url, &finalize_url, &nonce, &payload);
let res = post(&app, finalize_url.strip_prefix(common::HOST).unwrap(), body).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(axum::http::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok()),
Some("5"),
"a processing order must pace the client's polling"
);
let order = body_json(res).await;
assert_eq!(order["status"], "processing");
assert!(
order.get("certificate").is_none(),
"no certificate exists yet"
);
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &order_url, &nonce);
let res = post(&app, order_path, body).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(axum::http::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok()),
Some("5")
);
assert_eq!(body_json(res).await["status"], "processing");
}
#[tokio::test]
async fn finalize_with_a_local_signer_is_queued_too() {
let app = test_app().await;
let signer = EcSigner::new();
let (account_url, order_url) = ready_order(&app, &signer).await;
let finalize_url = format!("{order_url}/finalize");
let nonce = fetch_nonce(&app).await;
let payload = json!({ "csr": make_csr("example.com") });
let body = signer.sign_kid(&account_url, &finalize_url, &nonce, &payload);
let res = post(&app, finalize_url.strip_prefix(common::HOST).unwrap(), body).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(axum::http::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok()),
Some("5"),
"a processing order must pace the client's polling"
);
let order = body_json(res).await;
assert_eq!(order["status"], "processing");
assert!(order.get("certificate").is_none());
let order = await_order_status(&app, &signer, &account_url, &order_url, "valid").await;
assert!(order["certificate"].as_str().is_some());
}
#[tokio::test]
async fn concurrent_validations_of_one_order_still_promote_it() {
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": "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 authz_urls = order_authz_urls(&app, &signer, &account_url, &order_url).await;
assert_eq!(authz_urls.len(), 2);
let mut bodies = Vec::new();
for authz_url in &authz_urls {
let challenge_url = first_challenge_url(&app, &signer, &account_url, authz_url).await;
let path = challenge_url
.strip_prefix(common::HOST)
.unwrap()
.to_string();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid(&account_url, &challenge_url, &nonce, &json!({}));
bodies.push((path, body));
}
let (first, second) = tokio::join!(
post(&app, &bodies[0].0, bodies[0].1.clone()),
post(&app, &bodies[1].0, bodies[1].1.clone()),
);
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(second.status(), StatusCode::OK);
let order = await_order_status(&app, &signer, &account_url, &order_url, "ready").await;
assert_eq!(
order["status"], "ready",
"both authorizations are valid, so the order must be ready: {order}"
);
}
#[tokio::test]
async fn a_requested_not_after_reaches_the_issued_certificate() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = common::acme::register(&app, &signer).await;
let requested_not_after = time::OffsetDateTime::now_utc() + time::Duration::days(7);
let not_after = requested_not_after
.format(&time::format_description::well_known::Rfc3339)
.unwrap();
let nonce = fetch_nonce(&app).await;
let payload = json!({
"identifiers": [{ "type": "dns", "value": "example.com" }],
"notAfter": not_after,
});
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 = body_json(res).await;
common::acme::drive_to_ready(&app, &signer, &account_url, &order).await;
let order = common::acme::post_as_get(&app, &signer, &account_url, &order_url).await;
let res = common::acme::finalize(
&app,
&signer,
&account_url,
order["finalize"].as_str().unwrap(),
&["example.com"],
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let order = common::acme::await_order(&app, &signer, &account_url, &order_url).await;
let certificate_url = order["certificate"].as_str().unwrap().to_string();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &certificate_url, &nonce);
let res = post(&app, &common::acme::path_of(&certificate_url), body).await;
assert_eq!(res.status(), StatusCode::OK);
let chain = common::acme::body_text(res).await;
let leaf = acme_proxy_core::cert::leaf_der_from_chain(&chain).unwrap();
let (_not_before, cert_not_after) = acme_proxy_core::cert::cert_validity(&leaf).unwrap();
assert_eq!(
cert_not_after,
requested_not_after.unix_timestamp(),
"the certificate's notAfter must be the one the order asked for, \
not the CA's 90-day default"
);
}
#[tokio::test]
async fn a_not_after_beyond_the_ca_window_is_clamped_rather_than_honoured() {
let app = test_app().await;
let signer = EcSigner::new();
let account_url = common::acme::register(&app, &signer).await;
let far_future = time::OffsetDateTime::now_utc() + time::Duration::days(3650);
let nonce = fetch_nonce(&app).await;
let payload = json!({
"identifiers": [{ "type": "dns", "value": "example.com" }],
"notAfter": far_future
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
});
let body = signer.sign_kid(&account_url, NEW_ORDER_URL, &nonce, &payload);
let res = post(&app, &p("/newOrder"), body).await;
let order_url = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
let order = body_json(res).await;
common::acme::drive_to_ready(&app, &signer, &account_url, &order).await;
let order = common::acme::post_as_get(&app, &signer, &account_url, &order_url).await;
let res = common::acme::finalize(
&app,
&signer,
&account_url,
order["finalize"].as_str().unwrap(),
&["example.com"],
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let order = common::acme::await_order(&app, &signer, &account_url, &order_url).await;
let certificate_url = order["certificate"].as_str().unwrap().to_string();
let nonce = fetch_nonce(&app).await;
let body = signer.sign_kid_empty(&account_url, &certificate_url, &nonce);
let res = post(&app, &common::acme::path_of(&certificate_url), body).await;
let chain = common::acme::body_text(res).await;
let leaf = acme_proxy_core::cert::leaf_der_from_chain(&chain).unwrap();
let (_not_before, cert_not_after) = acme_proxy_core::cert::cert_validity(&leaf).unwrap();
assert!(
cert_not_after < far_future.unix_timestamp(),
"a request may narrow the CA's window, never widen it"
);
}
#[tokio::test]
async fn a_csr_whose_self_signature_does_not_verify_is_bad_csr() {
let (app, signer, account_url, order_url) = setup_ready_order().await;
let (csr_b64, _key) = common::make_csr_and_keypair("example.com");
let mut der = BASE64_URL_SAFE_NO_PAD.decode(&csr_b64).unwrap();
let last = der.len() - 1;
der[last] ^= 0xff;
let tampered = BASE64_URL_SAFE_NO_PAD.encode(&der);
let res = finalize(&app, &signer, &account_url, &order_url, &tampered).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:badCSR"
);
let res = finalize(&app, &signer, &account_url, &order_url, &csr_b64).await;
assert_eq!(
res.status(),
StatusCode::OK,
"a refused CSR must not have consumed the order"
);
}
#[tokio::test]
async fn the_certificate_of_another_account_is_unknown() {
let (app, owner, owner_url, order_url) = setup_ready_order().await;
let res = finalize(
&app,
&owner,
&owner_url,
&order_url,
&make_csr("example.com"),
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let certificate_url =
await_order_status(&app, &owner, &owner_url, &order_url, "valid").await["certificate"]
.as_str()
.expect("a finalized order carries its certificate URL")
.to_string();
let certificate_path = certificate_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = owner.sign_kid_empty(&owner_url, &certificate_url, &nonce);
assert_eq!(
post(&app, certificate_path, body).await.status(),
StatusCode::OK
);
let intruder = EcSigner::new();
let intruder_url = register(&app, &intruder).await;
let nonce = fetch_nonce(&app).await;
let body = intruder.sign_kid_empty(&intruder_url, &certificate_url, &nonce);
assert_problem(
post(&app, certificate_path, body).await,
StatusCode::BAD_REQUEST,
"urn:ietf:params:acme:error:malformed",
)
.await;
}