use std::time::Duration;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use ring::{
rand::SystemRandom,
signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _},
};
use serde_json::Value;
use tokio_rustls::rustls::sign::CertifiedKey;
use ts_http_util::{BytesBody, ClientExt as _, HeaderMap, Response, ResponseExt, StatusCode};
use url::Url;
use crate::cert::{CertError, PublishTxt, certified_key_from_pem};
pub const LETS_ENCRYPT_PRODUCTION_DIRECTORY: &str =
"https://acme-v02.api.letsencrypt.org/directory";
const MAX_POLL_TRIES: usize = 30;
const DEFAULT_POLL_DELAY: Duration = Duration::from_secs(2);
const ACME_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const ACME_MAX_RESPONSE: usize = 256 * 1024;
fn b64u(input: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(input)
}
fn sha256(input: &[u8]) -> Vec<u8> {
ring::digest::digest(&ring::digest::SHA256, input)
.as_ref()
.to_vec()
}
pub struct AcmeAccountKey {
key_pair: EcdsaKeyPair,
rng: SystemRandom,
}
impl AcmeAccountKey {
pub fn generate() -> Result<(Self, Vec<u8>), CertError> {
let rng = SystemRandom::new();
let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.map_err(|e| CertError::Acme(format!("generating account key: {e}")))?;
let der = pkcs8.as_ref().to_vec();
let key = Self::from_pkcs8(&der)?;
Ok((key, der))
}
pub fn from_pkcs8(der: &[u8]) -> Result<Self, CertError> {
let rng = SystemRandom::new();
let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, der, &rng)
.map_err(|e| CertError::Acme(format!("loading account key: {e}")))?;
Ok(Self { key_pair, rng })
}
fn public_xy(&self) -> (String, String) {
let pubkey = self.key_pair.public_key().as_ref();
let x = b64u(&pubkey[1..33]);
let y = b64u(&pubkey[33..65]);
(x, y)
}
fn public_jwk(&self) -> Value {
let (x, y) = self.public_xy();
serde_json::json!({"crv": "P-256", "kty": "EC", "x": x, "y": y})
}
fn canonical_jwk_json(&self) -> String {
let (x, y) = self.public_xy();
format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#)
}
fn jwk_thumbprint(&self) -> String {
b64u(&sha256(self.canonical_jwk_json().as_bytes()))
}
fn sign(&self, signing_input: &[u8]) -> Result<Vec<u8>, CertError> {
self.key_pair
.sign(&self.rng, signing_input)
.map(|sig| sig.as_ref().to_vec())
.map_err(|e| CertError::Acme(format!("signing JWS: {e}")))
}
}
enum JwsKey<'a> {
Jwk,
Kid(&'a str),
}
fn build_jws(
account_key: &AcmeAccountKey,
url: &str,
nonce: &str,
key: JwsKey<'_>,
payload: &[u8],
) -> Result<String, CertError> {
let mut protected = serde_json::Map::new();
protected.insert("alg".into(), Value::from("ES256"));
protected.insert("nonce".into(), Value::from(nonce));
protected.insert("url".into(), Value::from(url));
match key {
JwsKey::Jwk => {
protected.insert("jwk".into(), account_key.public_jwk());
}
JwsKey::Kid(kid) => {
protected.insert("kid".into(), Value::from(kid));
}
}
let protected_json = Value::Object(protected).to_string();
let protected_b64 = b64u(protected_json.as_bytes());
let payload_b64 = b64u(payload);
let signing_input = format!("{protected_b64}.{payload_b64}");
let signature = account_key.sign(signing_input.as_bytes())?;
let signature_b64 = b64u(&signature);
Ok(serde_json::json!({
"protected": protected_b64,
"payload": payload_b64,
"signature": signature_b64,
})
.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Dns01Challenge {
record_name: String,
txt_value: String,
}
fn prepare_dns01(name: &str, token: &str, account_key: &AcmeAccountKey) -> Dns01Challenge {
let thumbprint = account_key.jwk_thumbprint();
let key_authorization = format!("{token}.{thumbprint}");
let txt_value = b64u(&sha256(key_authorization.as_bytes()));
Dns01Challenge {
record_name: format!("_acme-challenge.{name}"),
txt_value,
}
}
struct Parts {
status: StatusCode,
headers: HeaderMap,
body: bytes::Bytes,
}
impl Parts {
fn header(&self, name: &str) -> Option<String> {
self.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
}
async fn consume<B>(resp: Response<B>) -> Result<Parts, CertError>
where
Response<B>: ResponseExt,
{
let status = resp.status();
let headers = resp.headers().clone();
let body = resp.collect_bytes().await.map_err(http_err)?;
check_body_size(body.len())?;
Ok(Parts {
status,
headers,
body,
})
}
fn check_body_size(len: usize) -> Result<(), CertError> {
if len > ACME_MAX_RESPONSE {
return Err(CertError::Acme("ACME response body too large".into()));
}
Ok(())
}
async fn with_timeout<F>(round_trip: F) -> Result<Parts, CertError>
where
F: core::future::Future<Output = Result<Parts, CertError>>,
{
match tokio::time::timeout(ACME_HTTP_TIMEOUT, round_trip).await {
Ok(result) => result,
Err(_elapsed) => Err(CertError::Acme("ACME HTTP request timed out".into())),
}
}
async fn acme_post(url: &Url, jws: String) -> Result<Parts, CertError> {
with_timeout(async {
let client = ts_http_util::http1::connect_tls::<BytesBody>(url)
.await
.map_err(http_err)?;
let headers = [(
ts_http_util::HeaderName::from_static("content-type"),
ts_http_util::HeaderValue::from_static("application/jose+json"),
)];
let resp = client
.post(url, headers, bytes::Bytes::from(jws).into())
.await
.map_err(http_err)?;
consume(resp).await
})
.await
}
async fn acme_get(url: &Url) -> Result<Parts, CertError> {
with_timeout(async {
let client = ts_http_util::http1::connect_tls::<BytesBody>(url)
.await
.map_err(http_err)?;
let resp = client.get(url, []).await.map_err(http_err)?;
consume(resp).await
})
.await
}
fn http_err(error: ts_http_util::Error) -> CertError {
CertError::Io(std::io::Error::other(error.to_string()))
}
struct Directory {
new_nonce: Url,
new_account: Url,
new_order: Url,
}
fn parse_directory(body: &[u8]) -> Result<Directory, CertError> {
let v: Value = serde_json::from_slice(body)
.map_err(|e| CertError::Acme(format!("directory JSON: {e}")))?;
let field = |k: &str| -> Result<Url, CertError> {
let s = v
.get(k)
.and_then(Value::as_str)
.ok_or_else(|| CertError::Acme(format!("directory missing {k}")))?;
Url::parse(s).map_err(|e| CertError::Acme(format!("directory {k} URL: {e}")))
};
Ok(Directory {
new_nonce: field("newNonce")?,
new_account: field("newAccount")?,
new_order: field("newOrder")?,
})
}
struct Session {
directory: Directory,
kid: String,
nonce: String,
}
impl Session {
fn take_nonce(&mut self) -> String {
std::mem::take(&mut self.nonce)
}
}
async fn signed_post(
session: &mut Session,
account_key: &AcmeAccountKey,
url: &Url,
payload: &[u8],
) -> Result<Parts, CertError> {
let nonce = session.take_nonce();
let jws = build_jws(
account_key,
url.as_str(),
&nonce,
JwsKey::Kid(&session.kid),
payload,
)?;
let parts = acme_post(url, jws).await?;
if let Some(n) = parts.header("replay-nonce") {
session.nonce = n;
}
Ok(parts)
}
pub struct IssuedCert {
pub certified: CertifiedKey,
pub cert_chain_pem: String,
pub key_pem: String,
}
pub async fn issue_certificate(
name: &str,
directory_url: &Url,
account_key: &AcmeAccountKey,
publisher: &(impl PublishTxt + Sync),
) -> Result<IssuedCert, CertError> {
let dir = acme_get(directory_url).await?;
let directory = parse_directory(&dir.body)?;
let nonce = acme_get(&directory.new_nonce)
.await?
.header("replay-nonce")
.ok_or_else(|| CertError::Acme("newNonce response missing Replay-Nonce".into()))?;
let account_payload = serde_json::json!({"termsOfServiceAgreed": true}).to_string();
let account_jws = build_jws(
account_key,
directory.new_account.as_str(),
&nonce,
JwsKey::Jwk,
account_payload.as_bytes(),
)?;
let account_resp = acme_post(&directory.new_account, account_jws).await?;
if !account_resp.status.is_success() {
return Err(status_err("newAccount", &account_resp));
}
let kid = account_resp
.header("location")
.ok_or_else(|| CertError::Acme("newAccount response missing Location (kid)".into()))?;
let nonce = account_resp
.header("replay-nonce")
.ok_or_else(|| CertError::Acme("newAccount response missing Replay-Nonce".into()))?;
let mut session = Session {
directory,
kid,
nonce,
};
let order_payload =
serde_json::json!({"identifiers": [{"type": "dns", "value": name}]}).to_string();
let new_order_url = session.directory.new_order.clone();
let order_resp = signed_post(
&mut session,
account_key,
&new_order_url,
order_payload.as_bytes(),
)
.await?;
if !order_resp.status.is_success() {
return Err(status_err("newOrder", &order_resp));
}
let order_url = order_resp
.header("location")
.ok_or_else(|| CertError::Acme("newOrder response missing Location (order URL)".into()))?;
let order: Value = serde_json::from_slice(&order_resp.body)
.map_err(|e| CertError::Acme(format!("newOrder JSON: {e}")))?;
let authorizations = order
.get("authorizations")
.and_then(Value::as_array)
.ok_or_else(|| CertError::Acme("order missing authorizations".into()))?
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>();
let finalize_url = order
.get("finalize")
.and_then(Value::as_str)
.ok_or_else(|| CertError::Acme("order missing finalize URL".into()))?
.to_string();
for authz_url in &authorizations {
let authz_url = Url::parse(authz_url)
.map_err(|e| CertError::Acme(format!("authorization URL: {e}")))?;
let authz_resp = signed_post(&mut session, account_key, &authz_url, &[]).await?;
if !authz_resp.status.is_success() {
return Err(status_err("authorization", &authz_resp));
}
let authz: Value = serde_json::from_slice(&authz_resp.body)
.map_err(|e| CertError::Acme(format!("authorization JSON: {e}")))?;
let challenge = authz
.get("challenges")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find(|c| c.get("type").and_then(Value::as_str) == Some("dns-01"))
.ok_or_else(|| CertError::Acme("authorization has no dns-01 challenge".into()))?;
let token = challenge
.get("token")
.and_then(Value::as_str)
.ok_or_else(|| CertError::Acme("dns-01 challenge missing token".into()))?;
let challenge_url = challenge
.get("url")
.and_then(Value::as_str)
.ok_or_else(|| CertError::Acme("dns-01 challenge missing url".into()))?
.to_string();
let dns01 = prepare_dns01(name, token, account_key);
publisher
.publish_txt(&dns01.record_name, &dns01.txt_value)
.await?;
let challenge_url = Url::parse(&challenge_url)
.map_err(|e| CertError::Acme(format!("challenge URL: {e}")))?;
let ready_resp = signed_post(&mut session, account_key, &challenge_url, b"{}").await?;
if !ready_resp.status.is_success() {
return Err(status_err("challenge-ready", &ready_resp));
}
poll_status(&mut session, account_key, &authz_url, "authorization").await?;
}
let cert_params = rcgen::CertificateParams::new(vec![name.to_string()])
.map_err(|e| CertError::Acme(format!("building CSR params: {e}")))?;
let cert_key = rcgen::KeyPair::generate()
.map_err(|e| CertError::Acme(format!("generating cert key: {e}")))?;
let csr = cert_params
.serialize_request(&cert_key)
.map_err(|e| CertError::Acme(format!("serializing CSR: {e}")))?;
let csr_b64 = b64u(csr.der());
let cert_key_pem = cert_key.serialize_pem();
let finalize_url =
Url::parse(&finalize_url).map_err(|e| CertError::Acme(format!("finalize URL: {e}")))?;
let finalize_payload = serde_json::json!({"csr": csr_b64}).to_string();
let finalize_resp = signed_post(
&mut session,
account_key,
&finalize_url,
finalize_payload.as_bytes(),
)
.await?;
if !finalize_resp.status.is_success() {
return Err(status_err("finalize", &finalize_resp));
}
let order_url =
Url::parse(&order_url).map_err(|e| CertError::Acme(format!("order URL: {e}")))?;
let final_order = poll_status(&mut session, account_key, &order_url, "order").await?;
let certificate_url = final_order
.get("certificate")
.and_then(Value::as_str)
.ok_or_else(|| CertError::Acme("valid order missing certificate URL".into()))?
.to_string();
let certificate_url = Url::parse(&certificate_url)
.map_err(|e| CertError::Acme(format!("certificate URL: {e}")))?;
let cert_resp = signed_post(&mut session, account_key, &certificate_url, &[]).await?;
if !cert_resp.status.is_success() {
return Err(status_err("certificate-download", &cert_resp));
}
let certified = certified_key_from_pem(&cert_resp.body, cert_key_pem.as_bytes())?;
let cert_chain_pem = String::from_utf8(cert_resp.body.to_vec())
.map_err(|_| CertError::Acme("certificate chain PEM was not valid UTF-8".into()))?;
Ok(IssuedCert {
certified,
cert_chain_pem,
key_pem: cert_key_pem,
})
}
async fn poll_status(
session: &mut Session,
account_key: &AcmeAccountKey,
url: &Url,
what: &str,
) -> Result<Value, CertError> {
for _ in 0..MAX_POLL_TRIES {
let resp = signed_post(session, account_key, url, &[]).await?;
if !resp.status.is_success() {
return Err(status_err(what, &resp));
}
let retry_after = resp
.header("retry-after")
.and_then(|s| s.trim().parse::<u64>().ok())
.map(Duration::from_secs);
let value: Value = serde_json::from_slice(&resp.body)
.map_err(|e| CertError::Acme(format!("{what} poll JSON: {e}")))?;
match value.get("status").and_then(Value::as_str) {
Some("valid") => return Ok(value),
Some("invalid") => {
return Err(CertError::Acme(format!("{what} became invalid: {value}")));
}
_ => {
tokio::time::sleep(retry_after.unwrap_or(DEFAULT_POLL_DELAY)).await;
}
}
}
Err(CertError::Acme(format!(
"{what} did not reach valid within {MAX_POLL_TRIES} polls"
)))
}
fn status_err(what: &str, parts: &Parts) -> CertError {
let mut preview = parts.body.to_vec();
preview.truncate(512);
let preview = String::from_utf8_lossy(&preview);
CertError::Acme(format!(
"{what} returned status {}: {preview}",
parts.status
))
}
#[cfg(all(test, feature = "acme"))]
mod tests {
use std::pin::Pin;
use super::*;
struct MockPublisher {
records: std::sync::Mutex<Vec<(String, String)>>,
}
impl MockPublisher {
fn new() -> Self {
Self {
records: std::sync::Mutex::new(Vec::new()),
}
}
}
impl PublishTxt for MockPublisher {
fn publish_txt(
&self,
name: &str,
value: &str,
) -> Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>
{
self.records
.lock()
.unwrap()
.push((name.to_string(), value.to_string()));
Box::pin(async { Ok(()) })
}
}
fn fresh_key() -> AcmeAccountKey {
let (_key, der) = AcmeAccountKey::generate().expect("generate");
AcmeAccountKey::from_pkcs8(&der).expect("reload")
}
#[test]
fn base64url_is_unpadded_and_url_safe() {
let encoded = b64u(&[0xfb, 0xff, 0xbf]);
assert!(!encoded.contains('='), "must have no padding: {encoded}");
assert!(!encoded.contains('+'), "must be URL-safe: {encoded}");
assert!(!encoded.contains('/'), "must be URL-safe: {encoded}");
assert_eq!(encoded, "-_-_");
}
#[test]
fn canonical_jwk_is_lexical_and_whitespace_free() {
let key = fresh_key();
let canonical = key.canonical_jwk_json();
assert!(canonical.starts_with(r#"{"crv":"P-256","kty":"EC","x":""#));
assert!(canonical.ends_with(r#""}"#));
assert!(!canonical.contains(' '));
assert!(!canonical.contains('\n'));
let i_crv = canonical.find("crv").unwrap();
let i_kty = canonical.find("kty").unwrap();
let i_x = canonical.find(r#""x":"#).unwrap();
let i_y = canonical.find(r#""y":"#).unwrap();
assert!(i_crv < i_kty && i_kty < i_x && i_x < i_y);
}
#[test]
fn thumbprint_is_43_char_unpadded_b64url() {
let key = fresh_key();
let tp = key.jwk_thumbprint();
assert_eq!(tp.len(), 43, "thumbprint: {tp}");
assert!(!tp.contains('='));
assert!(!tp.contains('+') && !tp.contains('/'));
}
#[test]
fn jwk_x_y_are_32_byte_coords() {
let key = fresh_key();
let (x, y) = key.public_xy();
assert_eq!(x.len(), 43, "x: {x}");
assert_eq!(y.len(), 43, "y: {y}");
assert!(!x.contains('=') && !y.contains('='));
}
#[test]
fn prepare_dns01_key_authorization_and_txt_value() {
let key = fresh_key();
let dns01 = prepare_dns01("host.tail1234.ts.net", "tok-EN-FACE-123", &key);
assert_eq!(dns01.record_name, "_acme-challenge.host.tail1234.ts.net");
assert_eq!(dns01.txt_value.len(), 43, "txt: {}", dns01.txt_value);
assert!(!dns01.txt_value.contains('='));
let key_auth = format!("tok-EN-FACE-123.{}", key.jwk_thumbprint());
let expected = b64u(&sha256(key_auth.as_bytes()));
assert_eq!(dns01.txt_value, expected);
}
#[test]
fn jws_has_three_fields_and_verifiable_signature() {
let key = fresh_key();
let jws = build_jws(
&key,
"https://acme.example/new-order",
"abc-nonce",
JwsKey::Kid("https://acme.example/acct/1"),
b"{}",
)
.expect("build jws");
let v: Value = serde_json::from_str(&jws).unwrap();
let protected_b64 = v.get("protected").and_then(Value::as_str).unwrap();
let payload_b64 = v.get("payload").and_then(Value::as_str).unwrap();
let signature_b64 = v.get("signature").and_then(Value::as_str).unwrap();
let protected_json = URL_SAFE_NO_PAD.decode(protected_b64).unwrap();
let header: Value = serde_json::from_slice(&protected_json).unwrap();
assert_eq!(header.get("alg").and_then(Value::as_str), Some("ES256"));
assert_eq!(
header.get("nonce").and_then(Value::as_str),
Some("abc-nonce")
);
assert_eq!(
header.get("url").and_then(Value::as_str),
Some("https://acme.example/new-order")
);
assert_eq!(
header.get("kid").and_then(Value::as_str),
Some("https://acme.example/acct/1")
);
assert!(header.get("jwk").is_none());
let sig = URL_SAFE_NO_PAD.decode(signature_b64).unwrap();
assert_eq!(sig.len(), 64, "ES256 fixed signature is 64 bytes");
let signing_input = format!("{protected_b64}.{payload_b64}");
let peer = ring::signature::UnparsedPublicKey::new(
&ring::signature::ECDSA_P256_SHA256_FIXED,
key.key_pair.public_key().as_ref(),
);
peer.verify(signing_input.as_bytes(), &sig)
.expect("signature must verify");
}
#[test]
fn newaccount_jws_uses_jwk_not_kid() {
let key = fresh_key();
let jws = build_jws(
&key,
"https://acme.example/new-acct",
"n1",
JwsKey::Jwk,
br#"{"termsOfServiceAgreed":true}"#,
)
.expect("build jws");
let v: Value = serde_json::from_str(&jws).unwrap();
let protected_b64 = v.get("protected").and_then(Value::as_str).unwrap();
let header: Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(protected_b64).unwrap()).unwrap();
let jwk = header.get("jwk").expect("newAccount uses jwk header");
assert_eq!(jwk.get("crv").and_then(Value::as_str), Some("P-256"));
assert_eq!(jwk.get("kty").and_then(Value::as_str), Some("EC"));
assert!(header.get("kid").is_none());
}
#[tokio::test]
async fn mock_publisher_records_the_challenge() {
let key = fresh_key();
let publisher = MockPublisher::new();
let dns01 = prepare_dns01("host.tail1234.ts.net", "the-token", &key);
publisher
.publish_txt(&dns01.record_name, &dns01.txt_value)
.await
.unwrap();
let records = publisher.records.lock().unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].0, "_acme-challenge.host.tail1234.ts.net");
assert_eq!(records[0].1, dns01.txt_value);
}
#[test]
fn check_body_size_caps_at_max() {
check_body_size(0).expect("empty body ok");
check_body_size(ACME_MAX_RESPONSE).expect("exactly at cap ok");
let err = check_body_size(ACME_MAX_RESPONSE + 1).expect_err("over cap rejected");
assert!(matches!(err, CertError::Acme(m) if m.contains("too large")));
}
#[test]
fn generate_then_from_pkcs8_round_trips() {
let (k1, der) = AcmeAccountKey::generate().expect("generate");
let k2 = AcmeAccountKey::from_pkcs8(&der).expect("reload persisted DER");
assert_eq!(k1.canonical_jwk_json(), k2.canonical_jwk_json());
assert_eq!(k1.jwk_thumbprint(), k2.jwk_thumbprint());
let k3 = AcmeAccountKey::from_pkcs8(&der).expect("reload again");
assert_eq!(k2.jwk_thumbprint(), k3.jwk_thumbprint());
}
const FIXED_PKCS8_HEX: &str = "308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b0201010420ed5474cb46ef01de295207f9f91ae8a8cca0b9d9a3182c9355328442f2ecc55aa144034200041e9b8e358664e3b6a4bb56c2301efcfdca4120fcef7574ed1bf1287882adb32b5a2f5597fd7eb76e3dd8f3744e7f4c1dde4c7384a27acc78d53fbcd16f4bc062";
fn fixed_key() -> AcmeAccountKey {
let der: Vec<u8> = (0..FIXED_PKCS8_HEX.len())
.step_by(2)
.map(|i| u8::from_str_radix(&FIXED_PKCS8_HEX[i..i + 2], 16).unwrap())
.collect();
AcmeAccountKey::from_pkcs8(&der).expect("load fixed key")
}
#[test]
fn jwk_thumbprint_known_answer_rfc7638() {
let key = fixed_key();
const EXPECTED_CANONICAL: &str = r#"{"crv":"P-256","kty":"EC","x":"HpuONYZk47aku1bCMB78_cpBIPzvdXTtG_EoeIKtsys","y":"Wi9Vl_1-t2492PN0Tn9MHd5Mc4Siesx41T-80W9LwGI"}"#;
assert_eq!(
key.canonical_jwk_json(),
EXPECTED_CANONICAL,
"canonical JWK must be byte-identical (member order + no whitespace)"
);
let expected_thumbprint = URL_SAFE_NO_PAD.encode(
ring::digest::digest(&ring::digest::SHA256, EXPECTED_CANONICAL.as_bytes()).as_ref(),
);
assert_eq!(
key.jwk_thumbprint(),
expected_thumbprint,
"thumbprint must equal base64url_nopad(SHA256(canonical JWK))"
);
assert_eq!(
key.jwk_thumbprint(),
"8NZV0yNd0fBPk--o9T4HK4Koyyb9cv_I9w5TfuDqiqo"
);
}
#[test]
fn public_jwk_header_member_order_matches_canonical() {
let key = fixed_key();
let header_jwk = serde_json::to_string(&key.public_jwk()).unwrap();
assert_eq!(
header_jwk,
key.canonical_jwk_json(),
"newAccount header JWK must be byte-identical-ordered to the thumbprint input"
);
}
#[test]
fn parse_directory_extracts_three_endpoints() {
let body = br#"{
"newNonce": "https://acme.example/acme/new-nonce",
"newAccount": "https://acme.example/acme/new-acct",
"newOrder": "https://acme.example/acme/new-order",
"revokeCert": "https://acme.example/acme/revoke-cert",
"meta": {"termsOfService": "https://acme.example/tos"}
}"#;
let dir = match parse_directory(body) {
Ok(d) => d,
Err(e) => panic!("valid directory must parse: {e:?}"),
};
assert_eq!(
dir.new_nonce.as_str(),
"https://acme.example/acme/new-nonce"
);
assert_eq!(
dir.new_account.as_str(),
"https://acme.example/acme/new-acct"
);
assert_eq!(
dir.new_order.as_str(),
"https://acme.example/acme/new-order"
);
}
#[test]
fn parse_directory_missing_field_errors() {
let body = br#"{
"newNonce": "https://acme.example/acme/new-nonce",
"newAccount": "https://acme.example/acme/new-acct"
}"#;
let err = match parse_directory(body) {
Err(e) => e,
Ok(_) => panic!("missing newOrder must error"),
};
assert!(
matches!(err, CertError::Acme(m) if m.contains("newOrder")),
"error must name the missing field"
);
}
#[test]
fn parse_directory_bad_url_errors() {
let body = br#"{
"newNonce": "not a url",
"newAccount": "https://acme.example/acme/new-acct",
"newOrder": "https://acme.example/acme/new-order"
}"#;
let err = match parse_directory(body) {
Err(e) => e,
Ok(_) => panic!("invalid URL must error"),
};
assert!(matches!(err, CertError::Acme(_)), "got {err:?}");
}
#[test]
fn parse_directory_non_json_errors() {
let err = match parse_directory(b"<html>not json</html>") {
Err(e) => e,
Ok(_) => panic!("non-JSON must error"),
};
assert!(
matches!(err, CertError::Acme(m) if m.contains("directory JSON")),
"error must indicate a directory JSON parse failure"
);
}
#[test]
fn parts_header_read_and_absent() {
let mut headers = HeaderMap::new();
headers.insert(
ts_http_util::HeaderName::from_static("replay-nonce"),
ts_http_util::HeaderValue::from_static("nonce-abc-123"),
);
headers.insert(
ts_http_util::HeaderName::from_static("location"),
ts_http_util::HeaderValue::from_static("https://acme.example/acct/42"),
);
let parts = Parts {
status: StatusCode::OK,
headers,
body: bytes::Bytes::new(),
};
assert_eq!(
parts.header("Replay-Nonce").as_deref(),
Some("nonce-abc-123")
);
assert_eq!(
parts.header("replay-nonce").as_deref(),
Some("nonce-abc-123")
);
assert_eq!(
parts.header("location").as_deref(),
Some("https://acme.example/acct/42")
);
assert_eq!(parts.header("retry-after"), None);
}
#[test]
fn session_take_nonce_consumes_then_empties() {
let directory = match parse_directory(
br#"{
"newNonce": "https://acme.example/n",
"newAccount": "https://acme.example/a",
"newOrder": "https://acme.example/o"
}"#,
) {
Ok(d) => d,
Err(e) => panic!("directory must parse: {e:?}"),
};
let mut session = Session {
directory,
kid: "https://acme.example/acct/1".to_string(),
nonce: "first-nonce".to_string(),
};
assert_eq!(session.take_nonce(), "first-nonce");
assert_eq!(session.take_nonce(), "");
session.nonce = "second-nonce".to_string();
assert_eq!(session.take_nonce(), "second-nonce");
}
#[test]
fn status_err_includes_step_status_and_body_preview() {
let parts = Parts {
status: StatusCode::FORBIDDEN,
headers: HeaderMap::new(),
body: bytes::Bytes::from_static(
br#"{"type":"urn:ietf:params:acme:error:unauthorized"}"#,
),
};
let err = status_err("newOrder", &parts);
let CertError::Acme(msg) = err else {
panic!("expected CertError::Acme, got {err:?}");
};
assert!(msg.contains("newOrder"), "names the step: {msg}");
assert!(msg.contains("403"), "includes the status: {msg}");
assert!(msg.contains("unauthorized"), "previews the body: {msg}");
}
#[test]
fn status_err_truncates_long_body() {
let big = vec![b'x'; 4096];
let parts = Parts {
status: StatusCode::INTERNAL_SERVER_ERROR,
headers: HeaderMap::new(),
body: bytes::Bytes::from(big),
};
let CertError::Acme(msg) = status_err("finalize", &parts) else {
panic!("expected CertError::Acme");
};
assert!(
msg.len() < 700,
"preview must be truncated, got {} chars",
msg.len()
);
assert!(msg.contains("finalize") && msg.contains("500"));
}
}