use std::sync::Arc;
use acme_proxy::sqlite::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();
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);
}
}
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);
}
#[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 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.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 = body_json(res).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.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 = authz_urls[0].rsplit('/').next().unwrap().to_string();
sqlx::query("DELETE FROM challenges WHERE authz_id != ?;")
.bind(&surviving)
.execute(&db.pool)
.await
.unwrap();
sqlx::query("DELETE FROM authorizations WHERE id != ?;")
.bind(&surviving)
.execute(&db.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 = body_json(res).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"
);
}