use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use base64::prelude::*;
use http_body_util::BodyExt;
use serde_json::{Value, json};
use std::sync::Arc;
use tower::ServiceExt;
mod common;
use acme_proxy_store::audit::AuditEntry;
use acme_proxy_store::audit::AuditQuery;
use acme_proxy_store::db::Database;
use common::{
EcSigner, TestSigner, body_json, fetch_nonce_from, first_certificate, make_csr, p, post_from,
test_app_with_db,
};
const CLIENT: &str = "203.0.113.7:40000";
const OTHER_CLIENT: &str = "198.51.100.4:40000";
const NEW_ACCOUNT_URL: &str = "http://localhost:3000/profile/default/newAccount";
const NEW_ORDER_URL: &str = "http://localhost:3000/profile/default/newOrder";
const REVOKE_URL: &str = "http://localhost:3000/profile/default/revokeCert";
async fn rows(database: &Arc<Database>) -> Vec<AuditEntry> {
AuditEntry::search(
&AuditQuery {
limit: 100,
..AuditQuery::default()
},
database,
)
.await
.unwrap()
.0
}
async fn one_row(database: &Arc<Database>) -> AuditEntry {
let mut rows = rows(database).await;
assert_eq!(rows.len(), 1, "expected exactly one audit row: {rows:?}");
rows.remove(0)
}
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, peer: &str) -> String {
let nonce = fetch_nonce_from(app, peer).await;
let body = signer.sign(NEW_ACCOUNT_URL, &nonce, &json!({}));
let res = post_from(app, &p("/newAccount"), body, peer).await;
assert_eq!(res.status(), StatusCode::CREATED);
res.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string()
}
async fn ready_order(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
peer: &str,
) -> String {
let nonce = fetch_nonce_from(app, peer).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_from(app, &p("/newOrder"), body, peer).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;
let authz_url = order["authorizations"][0].as_str().unwrap().to_string();
let nonce = fetch_nonce_from(app, peer).await;
let body = signer.sign_kid_empty(account_url, &authz_url, &nonce);
let res = post_from(
app,
authz_url.strip_prefix(common::HOST).unwrap(),
body,
peer,
)
.await;
let authz = body_json(res).await;
let challenge_url = authz["challenges"][0]["url"].as_str().unwrap().to_string();
for _ in 0..600 {
let nonce = fetch_nonce_from(app, peer).await;
let body = signer.sign_kid(account_url, &challenge_url, &nonce, &json!({}));
let res = post_from(
app,
challenge_url.strip_prefix(common::HOST).unwrap(),
body,
peer,
)
.await;
let status = body_json(res).await["status"].clone();
if status == "valid" {
return order_url;
}
assert_eq!(status, "processing", "the challenge must not fail here");
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!("the challenge never became valid");
}
async fn finalize(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order_url: &str,
csr: &str,
peer: &str,
) -> Response {
let finalize_url = format!("{order_url}/finalize");
let nonce = fetch_nonce_from(app, peer).await;
let payload = json!({ "csr": csr });
let body = signer.sign_kid(account_url, &finalize_url, &nonce, &payload);
post_from(
app,
finalize_url.strip_prefix(common::HOST).unwrap(),
body,
peer,
)
.await
}
async fn await_order_from(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order_url: &str,
peer: &str,
) -> Value {
for _ in 0..600 {
let nonce = fetch_nonce_from(app, peer).await;
let body = signer.sign_kid_empty(account_url, order_url, &nonce);
let res = post_from(
app,
order_url.strip_prefix(common::HOST).unwrap(),
body,
peer,
)
.await;
let order = body_json(res).await;
if order["status"] != "processing" {
return order;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!("`{order_url}` never left `processing`");
}
async fn issue(app: &Router, signer: &impl TestSigner, account_url: &str, peer: &str) -> String {
let order_url = ready_order(app, signer, account_url, peer).await;
let res = finalize(
app,
signer,
account_url,
&order_url,
&make_csr("example.com"),
peer,
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let order = await_order_from(app, signer, account_url, &order_url, peer).await;
let cert_url = order["certificate"].as_str().unwrap().to_string();
let nonce = fetch_nonce_from(app, peer).await;
let body = signer.sign_kid_empty(account_url, &cert_url, &nonce);
let res = post_from(
app,
cert_url.strip_prefix(common::HOST).unwrap(),
body,
peer,
)
.await;
body_text(res).await
}
async fn account_row(database: &Arc<Database>) -> Value {
let row: (Option<String>, Option<i64>, Option<String>, Option<String>) = sqlx::query_as(
"SELECT created_ip, last_seen_at, last_seen_ip, last_seen_ptr FROM accounts LIMIT 1;",
)
.fetch_one(database.raw_pool())
.await
.unwrap();
json!({
"created_ip": row.0,
"last_seen_at": row.1,
"last_seen_ip": row.2,
"last_seen_ptr": row.3,
})
}
#[tokio::test]
async fn an_account_records_where_it_was_created_and_where_it_was_last_used() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
let row = account_row(&database).await;
assert_eq!(row["created_ip"], "203.0.113.7");
assert_eq!(row["last_seen_ip"], "203.0.113.7");
assert!(row["last_seen_at"].is_i64());
assert_eq!(row["last_seen_ptr"], Value::Null);
let nonce = fetch_nonce_from(&app, CLIENT).await;
let body = signer.sign_kid(&account_url, &account_url, &nonce, &json!({}));
let res = post_from(
&app,
account_url.strip_prefix(common::HOST).unwrap(),
body,
CLIENT,
)
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(account_row(&database).await["last_seen_ip"], "203.0.113.7");
let nonce = fetch_nonce_from(&app, OTHER_CLIENT).await;
let body = signer.sign_kid(&account_url, &account_url, &nonce, &json!({}));
let res = post_from(
&app,
account_url.strip_prefix(common::HOST).unwrap(),
body,
OTHER_CLIENT,
)
.await;
assert_eq!(res.status(), StatusCode::OK);
let row = account_row(&database).await;
assert_eq!(row["last_seen_ip"], "198.51.100.4");
assert_eq!(row["created_ip"], "203.0.113.7");
}
#[tokio::test]
async fn a_rejected_request_does_not_move_the_last_seen_columns() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
let nonce = fetch_nonce_from(&app, CLIENT).await;
let body = signer.sign_kid(&account_url, &account_url, &nonce, &json!({}));
let path = account_url.strip_prefix(common::HOST).unwrap();
let first = post_from(&app, path, body.clone(), OTHER_CLIENT).await;
assert_eq!(first.status(), StatusCode::OK);
assert_eq!(account_row(&database).await["last_seen_ip"], "198.51.100.4");
let third = "192.0.2.9:40000";
let replayed = post_from(&app, path, body, third).await;
assert_eq!(replayed.status(), StatusCode::BAD_REQUEST);
assert_eq!(
account_row(&database).await["last_seen_ip"],
"198.51.100.4",
"a replayed nonce must not stamp the address it came from"
);
}
#[tokio::test]
async fn an_order_records_the_address_it_was_placed_from() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
ready_order(&app, &signer, &account_url, OTHER_CLIENT).await;
let (ip, ptr): (Option<String>, Option<String>) =
sqlx::query_as("SELECT created_ip, created_ptr FROM orders LIMIT 1;")
.fetch_one(database.raw_pool())
.await
.unwrap();
assert_eq!(ip.as_deref(), Some("198.51.100.4"));
assert_eq!(ptr, None);
}
#[tokio::test]
async fn issuing_a_certificate_writes_one_audit_row_with_the_requesters_address() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
assert!(
rows(&database).await.is_empty(),
"registering an account is not a CA action and writes no audit row"
);
issue(&app, &signer, &account_url, CLIENT).await;
let row = one_row(&database).await;
assert_eq!(row.event, "certificate_issued");
assert_eq!(row.outcome, "success");
assert_eq!(row.profile, "default");
assert_eq!(row.actor_kind, "acme");
assert_eq!(
row.actor_id.as_deref(),
account_url.rsplit('/').next(),
"the actor is the account that finalized"
);
assert_eq!(row.account_id, row.actor_id);
assert!(row.order_id.is_some());
assert!(row.cert_serial.is_some());
assert_eq!(row.identifiers, vec!["example.com"]);
assert_eq!(row.client_ip.as_deref(), Some("203.0.113.7"));
assert!(row.request_id.is_some());
assert!(row.reason.is_none());
}
#[tokio::test]
async fn a_refused_csr_is_recorded_as_a_failure_naming_the_problem() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
let order_url = ready_order(&app, &signer, &account_url, CLIENT).await;
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
&make_csr("evil.example.net"),
OTHER_CLIENT,
)
.await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let row = one_row(&database).await;
assert_eq!(row.event, "certificate_issue_failed");
assert_eq!(row.outcome, "failure");
assert_eq!(row.reason.as_deref(), Some("badCSR"));
assert!(row.detail.is_some());
assert!(row.cert_serial.is_none());
assert_eq!(row.identifiers, vec!["example.com"]);
assert_eq!(row.client_ip.as_deref(), Some("198.51.100.4"));
let res = finalize(
&app,
&signer,
&account_url,
&order_url,
"not-base64!!",
CLIENT,
)
.await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
assert_eq!(rows(&database).await.len(), 2);
}
#[tokio::test]
async fn revoking_over_acme_records_the_action_and_the_reason_code() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
let chain = issue(&app, &signer, &account_url, CLIENT).await;
let nonce = fetch_nonce_from(&app, OTHER_CLIENT).await;
let payload = json!({
"certificate": BASE64_URL_SAFE_NO_PAD.encode(first_certificate(&chain)),
"reason": 1,
});
let body = signer.sign_kid(&account_url, REVOKE_URL, &nonce, &payload);
let res = post_from(&app, &p("/revokeCert"), body, OTHER_CLIENT).await;
assert_eq!(res.status(), StatusCode::OK);
let after_revoke = rows(&database).await;
assert_eq!(after_revoke.len(), 2, "{after_revoke:?}");
let revoked = &after_revoke[0];
assert_eq!(revoked.event, "certificate_revoked");
assert_eq!(revoked.reason.as_deref(), Some("1"));
assert_eq!(revoked.client_ip.as_deref(), Some("198.51.100.4"));
assert_eq!(revoked.cert_serial, after_revoke[1].cert_serial);
let nonce = fetch_nonce_from(&app, CLIENT).await;
let payload = json!({
"certificate": BASE64_URL_SAFE_NO_PAD.encode(first_certificate(&chain)),
});
let body = signer.sign_kid(&account_url, REVOKE_URL, &nonce, &payload);
let res = post_from(&app, &p("/revokeCert"), body, CLIENT).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let after_retry = rows(&database).await;
assert_eq!(after_retry.len(), 3);
assert_eq!(after_retry[0].event, "certificate_revoke_failed");
assert_eq!(after_retry[0].reason.as_deref(), Some("alreadyRevoked"));
}
#[tokio::test]
async fn a_revocation_with_no_reason_records_no_reason_rather_than_zero() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
let chain = issue(&app, &signer, &account_url, CLIENT).await;
let nonce = fetch_nonce_from(&app, CLIENT).await;
let payload = json!({
"certificate": BASE64_URL_SAFE_NO_PAD.encode(first_certificate(&chain)),
});
let body = signer.sign_kid(&account_url, REVOKE_URL, &nonce, &payload);
assert_eq!(
post_from(&app, &p("/revokeCert"), body, CLIENT)
.await
.status(),
StatusCode::OK
);
let written = rows(&database).await;
assert_eq!(written[0].event, "certificate_revoked");
assert_eq!(written[0].reason, None);
assert!(
!written[0]
.to_json()
.as_object()
.unwrap()
.contains_key("reason")
);
}
#[tokio::test]
async fn an_unauthorized_revocation_is_recorded_with_the_serial_it_targeted() {
let (app, database) = test_app_with_db().await;
let owner = EcSigner::new();
let owner_url = register(&app, &owner, CLIENT).await;
let chain = issue(&app, &owner, &owner_url, CLIENT).await;
let stranger = EcSigner::new();
let stranger_url = register(&app, &stranger, OTHER_CLIENT).await;
let nonce = fetch_nonce_from(&app, OTHER_CLIENT).await;
let payload = json!({
"certificate": BASE64_URL_SAFE_NO_PAD.encode(first_certificate(&chain)),
});
let body = stranger.sign_kid(&stranger_url, REVOKE_URL, &nonce, &payload);
let res = post_from(&app, &p("/revokeCert"), body, OTHER_CLIENT).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let written = rows(&database).await;
assert_eq!(written[0].event, "certificate_revoke_failed");
assert_eq!(written[0].reason.as_deref(), Some("unauthorized"));
assert_eq!(
written[0].actor_id.as_deref(),
stranger_url.rsplit('/').next()
);
assert_eq!(written[0].client_ip.as_deref(), Some("198.51.100.4"));
assert!(written[0].cert_serial.is_some());
}
#[tokio::test]
async fn revoking_an_unknown_certificate_is_recorded_as_a_refusal() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
let unrelated = rcgen::generate_simple_self_signed(vec!["other.example.com".to_string()])
.unwrap()
.cert
.der()
.to_vec();
let nonce = fetch_nonce_from(&app, CLIENT).await;
let payload = json!({ "certificate": BASE64_URL_SAFE_NO_PAD.encode(&unrelated) });
let body = signer.sign_kid(&account_url, REVOKE_URL, &nonce, &payload);
let res = post_from(&app, &p("/revokeCert"), body, CLIENT).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let row = one_row(&database).await;
assert_eq!(row.event, "certificate_revoke_failed");
assert_eq!(row.reason.as_deref(), Some("malformed"));
assert!(row.order_id.is_none(), "there is no order to name");
assert!(
row.cert_serial.is_some(),
"but the serial tried is recorded"
);
}
#[tokio::test]
async fn an_unparsable_revocation_payload_writes_no_row() {
let (app, database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
for certificate in ["not-base64!!", &BASE64_URL_SAFE_NO_PAD.encode([1u8, 2, 3])] {
let nonce = fetch_nonce_from(&app, CLIENT).await;
let payload = json!({ "certificate": certificate });
let body = signer.sign_kid(&account_url, REVOKE_URL, &nonce, &payload);
let res = post_from(&app, &p("/revokeCert"), body, CLIENT).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
assert!(rows(&database).await.is_empty());
}
#[tokio::test]
async fn the_acme_objects_expose_none_of_the_traceability_columns() {
let (app, _database) = test_app_with_db().await;
let signer = EcSigner::new();
let account_url = register(&app, &signer, CLIENT).await;
let order_url = ready_order(&app, &signer, &account_url, CLIENT).await;
let nonce = fetch_nonce_from(&app, CLIENT).await;
let body = signer.sign_kid_empty(&account_url, &order_url, &nonce);
let order = body_json(
post_from(
&app,
order_url.strip_prefix(common::HOST).unwrap(),
body,
CLIENT,
)
.await,
)
.await;
let nonce = fetch_nonce_from(&app, CLIENT).await;
let body = signer.sign_kid(&account_url, &account_url, &nonce, &json!({}));
let account = body_json(
post_from(
&app,
account_url.strip_prefix(common::HOST).unwrap(),
body,
CLIENT,
)
.await,
)
.await;
for object in [&order, &account] {
let text = object.to_string();
assert!(!text.contains("203.0.113.7"), "{text}");
for member in ["createdIp", "createdPtr", "lastSeenAt", "lastSeenIp"] {
assert!(
!object.as_object().unwrap().contains_key(member),
"{member} leaked into {text}"
);
}
}
}
#[tokio::test]
async fn the_unauthenticated_surfaces_write_no_audit_rows() {
let (app, database) = test_app_with_db().await;
for path in ["/health", &p("/directory"), &p("/crl")] {
let res = app
.clone()
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert!(res.status().is_success(), "{path}: {}", res.status());
}
assert!(rows(&database).await.is_empty());
}