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, RsaSigner, TestSigner, body_json, fetch_nonce, p, test_app};
const BASE: &str = common::BASE;
const NEW_ACCOUNT_URL: &str = "http://localhost:3000/profile/default/newAccount";
const KEY_CHANGE_URL: &str = "http://localhost:3000/profile/default/keyChange";
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 key_change(app: &Router, body: String) -> Response {
post(app, &p("/keyChange"), body).await
}
fn build_inner_value(new_signer: &impl TestSigner, url: &str, payload: &Value) -> Value {
let inner_jws = new_signer.sign_inner(url, payload);
serde_json::from_str(&inner_jws).unwrap()
}
fn key_change_body(
old_signer: &impl TestSigner,
new_signer: &impl TestSigner,
account_url: &str,
nonce: &str,
) -> String {
let inner_payload = json!({ "account": account_url, "oldKey": old_signer.jwk() });
let inner_value = build_inner_value(new_signer, KEY_CHANGE_URL, &inner_payload);
old_signer.sign_kid(account_url, KEY_CHANGE_URL, nonce, &inner_value)
}
async fn assert_malformed(res: Response) -> Value {
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:malformed");
problem
}
#[tokio::test]
async fn key_change_succeeds_ec() {
let app = test_app().await;
let old_signer = EcSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let path = account_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = key_change_body(&old_signer, &new_signer, &account_url, &nonce);
let res = key_change(&app, body).await;
assert_eq!(res.status(), StatusCode::OK);
let account = body_json(res).await;
assert_eq!(account["status"], "valid");
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, &account_url, &nonce, &json!({}));
let res = post(&app, path, body).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:unauthorized"
);
let nonce = fetch_nonce(&app).await;
let body = new_signer.sign_kid(&account_url, &account_url, &nonce, &json!({}));
let res = post(&app, path, body).await;
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn key_change_succeeds_cross_algorithm_rsa_to_ec() {
let app = test_app().await;
let old_signer = RsaSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let path = account_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = key_change_body(&old_signer, &new_signer, &account_url, &nonce);
let res = key_change(&app, body).await;
assert_eq!(res.status(), StatusCode::OK);
let nonce = fetch_nonce(&app).await;
let body = new_signer.sign_kid(&account_url, &account_url, &nonce, &json!({}));
let res = post(&app, path, body).await;
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn key_change_rejects_inner_missing_jwk() {
let app = test_app().await;
let old_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let protected = json!({ "alg": "ES256", "url": KEY_CHANGE_URL });
let protected_b64 = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&protected).unwrap());
let payload_b64 = BASE64_URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&json!({ "account": account_url, "oldKey": old_signer.jwk() })).unwrap(),
);
let inner_value = json!({
"protected": protected_b64,
"payload": payload_b64,
"signature": BASE64_URL_SAFE_NO_PAD.encode([0u8; 64]),
});
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, KEY_CHANGE_URL, &nonce, &inner_value);
assert_malformed(key_change(&app, body).await).await;
}
#[tokio::test]
async fn key_change_rejects_inner_malformed_protected_json() {
let app = test_app().await;
let old_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let inner_value = json!({
"protected": BASE64_URL_SAFE_NO_PAD.encode(b"not json"),
"payload": BASE64_URL_SAFE_NO_PAD.encode(b"{}"),
"signature": BASE64_URL_SAFE_NO_PAD.encode([0u8; 64]),
});
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, KEY_CHANGE_URL, &nonce, &inner_value);
assert_malformed(key_change(&app, body).await).await;
}
#[tokio::test]
async fn key_change_rejects_inner_malformed_protected_base64() {
let app = test_app().await;
let old_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let inner_value = json!({
"protected": "!!!not-base64!!!",
"payload": BASE64_URL_SAFE_NO_PAD.encode(b"{}"),
"signature": BASE64_URL_SAFE_NO_PAD.encode([0u8; 64]),
});
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, KEY_CHANGE_URL, &nonce, &inner_value);
assert_malformed(key_change(&app, body).await).await;
}
#[tokio::test]
async fn key_change_rejects_inner_signature_tampered() {
let app = test_app().await;
let old_signer = EcSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let inner_payload = json!({ "account": account_url, "oldKey": old_signer.jwk() });
let mut inner_value = build_inner_value(&new_signer, KEY_CHANGE_URL, &inner_payload);
inner_value["signature"] = json!(BASE64_URL_SAFE_NO_PAD.encode([0u8; 64]));
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, KEY_CHANGE_URL, &nonce, &inner_value);
let res = key_change(&app, body).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:unauthorized"
);
}
#[tokio::test]
async fn key_change_rejects_inner_url_mismatch() {
let app = test_app().await;
let old_signer = EcSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let inner_payload = json!({ "account": account_url, "oldKey": old_signer.jwk() });
let inner_value = build_inner_value(&new_signer, &format!("{BASE}/other"), &inner_payload);
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, KEY_CHANGE_URL, &nonce, &inner_value);
assert_malformed(key_change(&app, body).await).await;
}
#[tokio::test]
async fn key_change_rejects_account_mismatch() {
let app = test_app().await;
let old_signer = EcSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let other_signer = EcSigner::new();
let other_account_url = register(&app, &other_signer).await;
let inner_payload = json!({ "account": other_account_url, "oldKey": old_signer.jwk() });
let inner_value = build_inner_value(&new_signer, KEY_CHANGE_URL, &inner_payload);
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, KEY_CHANGE_URL, &nonce, &inner_value);
assert_malformed(key_change(&app, body).await).await;
}
#[tokio::test]
async fn key_change_rejects_old_key_mismatch() {
let app = test_app().await;
let old_signer = EcSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let unrelated_signer = EcSigner::new();
let inner_payload = json!({ "account": account_url, "oldKey": unrelated_signer.jwk() });
let inner_value = build_inner_value(&new_signer, KEY_CHANGE_URL, &inner_payload);
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, KEY_CHANGE_URL, &nonce, &inner_value);
assert_malformed(key_change(&app, body).await).await;
}
#[tokio::test]
async fn key_change_rejects_conflicting_new_key() {
let app = test_app().await;
let old_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let other_signer = EcSigner::new();
let other_account_url = register(&app, &other_signer).await;
let inner_payload = json!({ "account": account_url, "oldKey": old_signer.jwk() });
let inner_value = build_inner_value(&other_signer, KEY_CHANGE_URL, &inner_payload);
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, KEY_CHANGE_URL, &nonce, &inner_value);
let res = key_change(&app, body).await;
assert_eq!(res.status(), StatusCode::CONFLICT);
assert_eq!(
res.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/problem+json")
);
let location = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
assert_eq!(location, other_account_url);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:malformed");
assert_eq!(problem["status"], 409);
}
#[tokio::test]
async fn key_change_rejects_deactivated_account() {
let app = test_app().await;
let old_signer = EcSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let path = account_url.strip_prefix(common::HOST).unwrap();
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(
&account_url,
&account_url,
&nonce,
&json!({ "status": "deactivated" }),
);
let res = post(&app, path, body).await;
assert_eq!(res.status(), StatusCode::OK);
let nonce = fetch_nonce(&app).await;
let body = key_change_body(&old_signer, &new_signer, &account_url, &nonce);
let res = key_change(&app, body).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:unauthorized"
);
}
#[tokio::test]
async fn key_change_wrong_outer_url_is_malformed() {
let app = test_app().await;
let old_signer = EcSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let inner_payload = json!({ "account": account_url, "oldKey": old_signer.jwk() });
let inner_value = build_inner_value(&new_signer, KEY_CHANGE_URL, &inner_payload);
let nonce = fetch_nonce(&app).await;
let body = old_signer.sign_kid(&account_url, &format!("{BASE}/other"), &nonce, &inner_value);
assert_malformed(key_change(&app, body).await).await;
}
#[tokio::test]
async fn key_change_unknown_nonce_is_bad_nonce() {
let app = test_app().await;
let old_signer = EcSigner::new();
let new_signer = EcSigner::new();
let account_url = register(&app, &old_signer).await;
let inner_payload = json!({ "account": account_url, "oldKey": old_signer.jwk() });
let inner_value = build_inner_value(&new_signer, KEY_CHANGE_URL, &inner_payload);
let body = old_signer.sign_kid(
&account_url,
KEY_CHANGE_URL,
"00000000-0000-0000-0000-000000000000",
&inner_value,
);
let res = key_change(&app, body).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
assert_eq!(
body_json(res).await["type"],
"urn:ietf:params:acme:error:badNonce"
);
}
#[tokio::test]
async fn concurrent_key_changes_onto_one_key_leave_a_winner_and_a_conflict() {
const ROUNDS: usize = 12;
let (app, _db, _disk) = common::test_app_on_disk().await;
for round in 0..ROUNDS {
let signer_a = EcSigner::new();
let signer_b = EcSigner::new();
let account_a = register(&app, &signer_a).await;
let account_b = register(&app, &signer_b).await;
let new_key = EcSigner::new();
let nonce_a = fetch_nonce(&app).await;
let nonce_b = fetch_nonce(&app).await;
let body_a = key_change_body(&signer_a, &new_key, &account_a, &nonce_a);
let body_b = key_change_body(&signer_b, &new_key, &account_b, &nonce_b);
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
let mut tasks = Vec::with_capacity(2);
for body in [body_a, body_b] {
let app = app.clone();
let barrier = barrier.clone();
tasks.push(tokio::spawn(async move {
barrier.wait().await;
key_change(&app, body).await
}));
}
let mut statuses = Vec::with_capacity(2);
for task in tasks {
let res = task.await.unwrap();
let status = res.status();
assert_ne!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"round {round}: a lost rollover race is a 409, not a server error"
);
if status == StatusCode::CONFLICT {
let location = res
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
location == account_a || location == account_b,
"round {round}: 409 must name the account holding the key, got {location:?}"
);
let problem = body_json(res).await;
assert_eq!(problem["status"], 409, "round {round}");
}
statuses.push(status);
}
assert_eq!(
statuses.iter().filter(|s| **s == StatusCode::OK).count(),
1,
"round {round}: exactly one rollover may take the key, got {statuses:?}"
);
assert_eq!(
statuses
.iter()
.filter(|s| **s == StatusCode::CONFLICT)
.count(),
1,
"round {round}: the other must be refused with 409, got {statuses:?}"
);
}
}