Skip to main content

auth_framework/protocols/
acme.rs

1//! ACME (Automatic Certificate Management Environment) protocol — RFC 8555.
2//!
3//! Provides automated X.509 certificate issuance and lifecycle management.
4//! Supports HTTP-01 and DNS-01 challenge types for domain validation.
5//!
6//! # Architecture
7//!
8//! The module implements the client side of the ACME protocol:
9//!
10//! 1. **Account registration** — create an ACME account with the CA
11//! 2. **Order creation** — request a certificate for one or more domains
12//! 3. **Authorization** — prove control over the requested domains
13//! 4. **Challenge fulfillment** — HTTP-01 or DNS-01 validation
14//! 5. **Finalization** — submit CSR and receive the signed certificate
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use auth_framework::protocols::acme::{AcmeClient, AcmeConfig};
20//!
21//! # async fn example() -> auth_framework::errors::Result<()> {
22//! let config = AcmeConfig {
23//!     directory_url: "https://acme-v02.api.letsencrypt.org/directory".to_string(),
24//!     ..Default::default()
25//! };
26//! let client = AcmeClient::new(config).await?;
27//! let order = client.create_order(&["example.com"]).await?;
28//! # Ok(())
29//! # }
30//! ```
31
32use crate::errors::{AuthError, Result};
33use base64::Engine;
34use ring::rand::SystemRandom;
35use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair};
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38use std::collections::HashMap;
39use std::sync::Arc;
40use tokio::sync::RwLock;
41
42// ── Configuration ───────────────────────────────────────────────────
43
44/// ACME client configuration.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AcmeConfig {
47    /// ACME directory URL (e.g., Let's Encrypt production or staging).
48    pub directory_url: String,
49    /// Contact email addresses for the ACME account.
50    pub contact_emails: Vec<String>,
51    /// Whether to agree to the CA's terms of service.
52    pub agree_to_tos: bool,
53    /// HTTP request timeout in seconds.
54    pub timeout_secs: u64,
55}
56
57impl Default for AcmeConfig {
58    fn default() -> Self {
59        Self {
60            directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory".to_string(),
61            contact_emails: Vec::new(),
62            agree_to_tos: false,
63            timeout_secs: 30,
64        }
65    }
66}
67
68// ── ACME Directory ──────────────────────────────────────────────────
69
70/// ACME directory resource (RFC 8555 §7.1.1).
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct AcmeDirectory {
74    pub new_nonce: String,
75    pub new_account: String,
76    pub new_order: String,
77    #[serde(default)]
78    pub new_authz: Option<String>,
79    #[serde(default)]
80    pub revoke_cert: Option<String>,
81    #[serde(default)]
82    pub key_change: Option<String>,
83    #[serde(default)]
84    pub meta: Option<AcmeDirectoryMeta>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct AcmeDirectoryMeta {
90    #[serde(default)]
91    pub terms_of_service: Option<String>,
92    #[serde(default)]
93    pub website: Option<String>,
94    #[serde(default)]
95    pub caa_identities: Vec<String>,
96    #[serde(default)]
97    pub external_account_required: bool,
98}
99
100// ── ACME Account ────────────────────────────────────────────────────
101
102/// ACME account status.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum AccountStatus {
106    Valid,
107    Deactivated,
108    Revoked,
109}
110
111/// ACME account resource (RFC 8555 §7.1.2).
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct AcmeAccount {
115    pub status: AccountStatus,
116    #[serde(default)]
117    pub contact: Vec<String>,
118    #[serde(default)]
119    pub terms_of_service_agreed: bool,
120    #[serde(default)]
121    pub orders: Option<String>,
122}
123
124// ── ACME Order ──────────────────────────────────────────────────────
125
126/// ACME order status (RFC 8555 §7.1.3).
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum OrderStatus {
130    Pending,
131    Ready,
132    Processing,
133    Valid,
134    Invalid,
135}
136
137/// Identifier in an ACME order.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct AcmeIdentifier {
140    #[serde(rename = "type")]
141    pub id_type: String,
142    pub value: String,
143}
144
145/// ACME order resource (RFC 8555 §7.1.3).
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct AcmeOrder {
149    pub status: OrderStatus,
150    #[serde(default)]
151    pub expires: Option<String>,
152    pub identifiers: Vec<AcmeIdentifier>,
153    #[serde(default)]
154    pub not_before: Option<String>,
155    #[serde(default)]
156    pub not_after: Option<String>,
157    pub authorizations: Vec<String>,
158    pub finalize: String,
159    #[serde(default)]
160    pub certificate: Option<String>,
161}
162
163// ── ACME Authorization ──────────────────────────────────────────────
164
165/// ACME authorization status (RFC 8555 §7.1.4).
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "lowercase")]
168pub enum AuthorizationStatus {
169    Pending,
170    Valid,
171    Invalid,
172    Deactivated,
173    Expired,
174    Revoked,
175}
176
177/// ACME authorization resource.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct AcmeAuthorization {
180    pub identifier: AcmeIdentifier,
181    pub status: AuthorizationStatus,
182    #[serde(default)]
183    pub expires: Option<String>,
184    pub challenges: Vec<AcmeChallenge>,
185    #[serde(default)]
186    pub wildcard: bool,
187}
188
189// ── ACME Challenge ──────────────────────────────────────────────────
190
191/// ACME challenge types.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub enum ChallengeType {
194    #[serde(rename = "http-01")]
195    Http01,
196    #[serde(rename = "dns-01")]
197    Dns01,
198    #[serde(rename = "tls-alpn-01")]
199    TlsAlpn01,
200}
201
202/// ACME challenge status.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "lowercase")]
205pub enum ChallengeStatus {
206    Pending,
207    Processing,
208    Valid,
209    Invalid,
210}
211
212/// ACME challenge resource (RFC 8555 §7.1.5).
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct AcmeChallenge {
215    #[serde(rename = "type")]
216    pub challenge_type: ChallengeType,
217    pub url: String,
218    pub status: ChallengeStatus,
219    pub token: String,
220    #[serde(default)]
221    pub validated: Option<String>,
222    #[serde(default)]
223    pub error: Option<serde_json::Value>,
224}
225
226// ── JWS / JWK helpers ───────────────────────────────────────────────
227
228/// Compute the JWK Thumbprint (RFC 7638) of an ECDSA P-256 public key.
229fn jwk_thumbprint_p256(public_key: &[u8]) -> String {
230    // P-256 public key is 65 bytes: 0x04 || x(32) || y(32)
231    if public_key.len() != 65 || public_key[0] != 0x04 {
232        return String::new();
233    }
234    let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
235    let x = b64.encode(&public_key[1..33]);
236    let y = b64.encode(&public_key[33..65]);
237
238    // RFC 7638: lexicographic JSON with required members only
239    let thumbprint_input = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#);
240    let digest = Sha256::digest(thumbprint_input.as_bytes());
241    b64.encode(digest)
242}
243
244/// Build a JWK object from an ECDSA P-256 public key.
245fn build_p256_jwk(public_key: &[u8]) -> serde_json::Value {
246    let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
247    if public_key.len() == 65 && public_key[0] == 0x04 {
248        serde_json::json!({
249            "kty": "EC",
250            "crv": "P-256",
251            "x": b64.encode(&public_key[1..33]),
252            "y": b64.encode(&public_key[33..65]),
253        })
254    } else {
255        serde_json::json!({})
256    }
257}
258
259/// Create a JWS (JSON Web Signature) in Flattened JSON Serialization.
260///
261/// Per RFC 8555 §6.2, ACME requests use JWS with either `jwk` (for new accounts)
262/// or `kid` (for authenticated requests).
263fn create_jws(
264    key_pair: &EcdsaKeyPair,
265    url: &str,
266    nonce: &str,
267    payload: &str,
268    kid: Option<&str>,
269) -> Result<serde_json::Value> {
270    let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
271
272    let mut header = serde_json::json!({
273        "alg": "ES256",
274        "nonce": nonce,
275        "url": url,
276    });
277
278    if let Some(kid_url) = kid {
279        header["kid"] = serde_json::json!(kid_url);
280    } else {
281        header["jwk"] = build_p256_jwk(key_pair.public_key().as_ref());
282    }
283
284    let protected = b64.encode(header.to_string().as_bytes());
285    let payload_b64 = if payload.is_empty() {
286        String::new() // POST-as-GET (RFC 8555 §6.3)
287    } else {
288        b64.encode(payload.as_bytes())
289    };
290
291    let signing_input = format!("{protected}.{payload_b64}");
292    let rng = SystemRandom::new();
293    let signature = key_pair
294        .sign(&rng, signing_input.as_bytes())
295        .map_err(|_| AuthError::internal("ACME JWS signing failed"))?;
296
297    Ok(serde_json::json!({
298        "protected": protected,
299        "payload": payload_b64,
300        "signature": b64.encode(signature.as_ref()),
301    }))
302}
303
304// ── ACME Client ─────────────────────────────────────────────────────
305
306/// ACME protocol client for automated certificate management.
307pub struct AcmeClient {
308    config: AcmeConfig,
309    http: reqwest::Client,
310    key_pair: EcdsaKeyPair,
311    directory: AcmeDirectory,
312    account_url: Arc<RwLock<Option<String>>>,
313    nonce: Arc<RwLock<Option<String>>>,
314}
315
316impl AcmeClient {
317    /// Create a new ACME client and fetch the directory.
318    pub async fn new(config: AcmeConfig) -> Result<Self> {
319        let http = reqwest::Client::builder()
320            .timeout(std::time::Duration::from_secs(config.timeout_secs))
321            .build()
322            .map_err(|e| AuthError::internal(&format!("HTTP client init failed: {e}")))?;
323
324        // Generate ECDSA P-256 key pair for account operations
325        let rng = SystemRandom::new();
326        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
327            .map_err(|_| AuthError::internal("Failed to generate ACME account key"))?;
328        let key_pair =
329            EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
330                .map_err(|_| AuthError::internal("Failed to parse generated PKCS#8 key"))?;
331
332        // Fetch the ACME directory
333        let resp = http
334            .get(&config.directory_url)
335            .send()
336            .await
337            .map_err(|e| AuthError::internal(&format!("ACME directory fetch failed: {e}")))?;
338
339        let directory: AcmeDirectory = resp
340            .json()
341            .await
342            .map_err(|e| AuthError::internal(&format!("Invalid ACME directory response: {e}")))?;
343
344        Ok(Self {
345            config,
346            http,
347            key_pair,
348            directory,
349            account_url: Arc::new(RwLock::new(None)),
350            nonce: Arc::new(RwLock::new(None)),
351        })
352    }
353
354    /// Create an ACME client from an existing key pair and directory (for testing).
355    pub fn from_parts(
356        config: AcmeConfig,
357        key_pair: EcdsaKeyPair,
358        directory: AcmeDirectory,
359    ) -> Self {
360        Self {
361            http: reqwest::Client::new(),
362            config,
363            key_pair,
364            directory,
365            account_url: Arc::new(RwLock::new(None)),
366            nonce: Arc::new(RwLock::new(None)),
367        }
368    }
369
370    /// Get the ACME directory resource.
371    pub fn directory(&self) -> &AcmeDirectory {
372        &self.directory
373    }
374
375    /// Fetch a fresh anti-replay nonce from the ACME server.
376    async fn fetch_nonce(&self) -> Result<String> {
377        let resp = self
378            .http
379            .head(&self.directory.new_nonce)
380            .send()
381            .await
382            .map_err(|e| AuthError::internal(&format!("Nonce fetch failed: {e}")))?;
383
384        let nonce = resp
385            .headers()
386            .get("replay-nonce")
387            .and_then(|v| v.to_str().ok())
388            .ok_or_else(|| AuthError::internal("ACME server did not return Replay-Nonce header"))?
389            .to_string();
390
391        *self.nonce.write().await = Some(nonce.clone());
392        Ok(nonce)
393    }
394
395    /// Get the current nonce, fetching a new one if needed.
396    async fn get_nonce(&self) -> Result<String> {
397        let current = self.nonce.read().await.clone();
398        match current {
399            Some(n) => {
400                // Consume the nonce (single-use)
401                *self.nonce.write().await = None;
402                Ok(n)
403            }
404            None => self.fetch_nonce().await,
405        }
406    }
407
408    /// Make a signed ACME POST request.
409    async fn signed_request(&self, url: &str, payload: &str) -> Result<reqwest::Response> {
410        let nonce = self.get_nonce().await?;
411        let kid = self.account_url.read().await.clone();
412        let jws = create_jws(&self.key_pair, url, &nonce, payload, kid.as_deref())?;
413
414        let resp = self
415            .http
416            .post(url)
417            .header("Content-Type", "application/jose+json")
418            .json(&jws)
419            .send()
420            .await
421            .map_err(|e| AuthError::internal(&format!("ACME request failed: {e}")))?;
422
423        // Capture the new nonce from the response
424        if let Some(new_nonce) = resp
425            .headers()
426            .get("replay-nonce")
427            .and_then(|v| v.to_str().ok())
428        {
429            *self.nonce.write().await = Some(new_nonce.to_string());
430        }
431
432        Ok(resp)
433    }
434
435    /// Register a new ACME account (RFC 8555 §7.3).
436    pub async fn register_account(&self) -> Result<AcmeAccount> {
437        let contacts: Vec<String> = self
438            .config
439            .contact_emails
440            .iter()
441            .map(|e| format!("mailto:{e}"))
442            .collect();
443
444        let payload = serde_json::json!({
445            "termsOfServiceAgreed": self.config.agree_to_tos,
446            "contact": contacts,
447        });
448
449        let resp = self
450            .signed_request(&self.directory.new_account, &payload.to_string())
451            .await?;
452
453        // Store the account URL from the Location header
454        if let Some(location) = resp.headers().get("location").and_then(|v| v.to_str().ok()) {
455            *self.account_url.write().await = Some(location.to_string());
456        }
457
458        let account: AcmeAccount = resp
459            .json()
460            .await
461            .map_err(|e| AuthError::internal(&format!("Invalid account response: {e}")))?;
462
463        Ok(account)
464    }
465
466    /// Create a new certificate order (RFC 8555 §7.4).
467    pub async fn create_order(&self, domains: &[&str]) -> Result<AcmeOrder> {
468        if domains.is_empty() {
469            return Err(AuthError::validation(
470                "At least one domain is required for an ACME order",
471            ));
472        }
473
474        let identifiers: Vec<AcmeIdentifier> = domains
475            .iter()
476            .map(|d| AcmeIdentifier {
477                id_type: "dns".to_string(),
478                value: d.to_string(),
479            })
480            .collect();
481
482        let payload = serde_json::json!({
483            "identifiers": identifiers,
484        });
485
486        let resp = self
487            .signed_request(&self.directory.new_order, &payload.to_string())
488            .await?;
489
490        let order: AcmeOrder = resp
491            .json()
492            .await
493            .map_err(|e| AuthError::internal(&format!("Invalid order response: {e}")))?;
494
495        Ok(order)
496    }
497
498    /// Fetch an authorization resource (RFC 8555 §7.5).
499    pub async fn get_authorization(&self, authz_url: &str) -> Result<AcmeAuthorization> {
500        let resp = self.signed_request(authz_url, "").await?;
501        let authz: AcmeAuthorization = resp
502            .json()
503            .await
504            .map_err(|e| AuthError::internal(&format!("Invalid authorization response: {e}")))?;
505        Ok(authz)
506    }
507
508    /// Compute the key authorization string for a challenge (RFC 8555 §8.1).
509    ///
510    /// `key_authorization = token || '.' || base64url(JWK_Thumbprint)`
511    pub fn key_authorization(&self, token: &str) -> String {
512        let thumbprint = jwk_thumbprint_p256(self.key_pair.public_key().as_ref());
513        format!("{token}.{thumbprint}")
514    }
515
516    /// Compute the DNS-01 challenge record value (RFC 8555 §8.4).
517    ///
518    /// Returns the base64url-encoded SHA-256 of the key authorization.
519    pub fn dns01_record_value(&self, token: &str) -> String {
520        let key_authz = self.key_authorization(token);
521        let digest = Sha256::digest(key_authz.as_bytes());
522        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
523    }
524
525    /// Respond to a challenge to begin validation (RFC 8555 §7.5.1).
526    pub async fn respond_to_challenge(&self, challenge_url: &str) -> Result<AcmeChallenge> {
527        let resp = self.signed_request(challenge_url, "{}").await?;
528        let challenge: AcmeChallenge = resp
529            .json()
530            .await
531            .map_err(|e| AuthError::internal(&format!("Challenge response error: {e}")))?;
532        Ok(challenge)
533    }
534
535    /// Finalize an order by submitting a CSR (RFC 8555 §7.4).
536    pub async fn finalize_order(&self, finalize_url: &str, csr_der: &[u8]) -> Result<AcmeOrder> {
537        let csr_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(csr_der);
538        let payload = serde_json::json!({
539            "csr": csr_b64,
540        });
541
542        let resp = self
543            .signed_request(finalize_url, &payload.to_string())
544            .await?;
545
546        let order: AcmeOrder = resp
547            .json()
548            .await
549            .map_err(|e| AuthError::internal(&format!("Finalize response error: {e}")))?;
550
551        Ok(order)
552    }
553
554    /// Download the issued certificate chain (RFC 8555 §7.4.2).
555    pub async fn download_certificate(&self, cert_url: &str) -> Result<String> {
556        let resp = self.signed_request(cert_url, "").await?;
557        let pem = resp
558            .text()
559            .await
560            .map_err(|e| AuthError::internal(&format!("Certificate download error: {e}")))?;
561        Ok(pem)
562    }
563
564    /// Get the JWK thumbprint of the account key.
565    pub fn account_thumbprint(&self) -> String {
566        jwk_thumbprint_p256(self.key_pair.public_key().as_ref())
567    }
568
569    /// Revoke a certificate (RFC 8555 §7.6).
570    ///
571    /// The `cert_der` must be the DER-encoded end-entity certificate.
572    /// `reason` is an optional RFC 5280 CRLReason code (0–10).
573    pub async fn revoke_certificate(&self, cert_der: &[u8], reason: Option<u8>) -> Result<()> {
574        let revoke_url =
575            self.directory.revoke_cert.as_ref().ok_or_else(|| {
576                AuthError::config("ACME directory does not provide a revokeCert URL")
577            })?;
578
579        let cert_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(cert_der);
580        let mut payload = serde_json::json!({
581            "certificate": cert_b64,
582        });
583        if let Some(r) = reason {
584            payload["reason"] = serde_json::json!(r);
585        }
586
587        let resp = self
588            .signed_request(revoke_url, &payload.to_string())
589            .await?;
590        let status = resp.status().as_u16();
591        if status == 200 {
592            Ok(())
593        } else {
594            let body = resp.text().await.unwrap_or_default();
595            Err(AuthError::internal(&format!(
596                "Certificate revocation failed (HTTP {status}): {body}"
597            )))
598        }
599    }
600}
601
602// ── Certificate Renewal Tracker ─────────────────────────────────────
603
604/// Tracks certificate expiry and manages renewal scheduling.
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct CertificateRecord {
607    /// Domains covered by this certificate.
608    pub domains: Vec<String>,
609    /// PEM-encoded certificate chain.
610    pub pem_chain: String,
611    /// When the certificate was issued (UNIX timestamp).
612    pub issued_at: u64,
613    /// When the certificate expires (UNIX timestamp).
614    pub expires_at: u64,
615    /// When renewal should be attempted (UNIX timestamp).
616    /// Typically 30 days before expiry for 90-day Let's Encrypt certs.
617    pub renew_at: u64,
618    /// ACME order finalize URL (for reference in logs/debugging).
619    pub order_url: Option<String>,
620}
621
622impl CertificateRecord {
623    /// Create a new certificate record.
624    ///
625    /// `renew_before_secs` is how many seconds before expiry to schedule renewal
626    /// (default: 30 days = 2_592_000 seconds).
627    pub fn new(
628        domains: Vec<String>,
629        pem_chain: String,
630        issued_at: u64,
631        expires_at: u64,
632        renew_before_secs: u64,
633    ) -> Self {
634        let renew_at = expires_at.saturating_sub(renew_before_secs);
635        Self {
636            domains,
637            pem_chain,
638            issued_at,
639            expires_at,
640            renew_at,
641            order_url: None,
642        }
643    }
644
645    /// Check if the certificate has expired.
646    pub fn is_expired(&self) -> bool {
647        let now = std::time::SystemTime::now()
648            .duration_since(std::time::UNIX_EPOCH)
649            .unwrap_or_default()
650            .as_secs();
651        now >= self.expires_at
652    }
653
654    /// Check if it's time to schedule a renewal.
655    pub fn needs_renewal(&self) -> bool {
656        let now = std::time::SystemTime::now()
657            .duration_since(std::time::UNIX_EPOCH)
658            .unwrap_or_default()
659            .as_secs();
660        now >= self.renew_at
661    }
662
663    /// Remaining time until expiry (seconds), or 0 if already expired.
664    pub fn remaining_secs(&self) -> u64 {
665        let now = std::time::SystemTime::now()
666            .duration_since(std::time::UNIX_EPOCH)
667            .unwrap_or_default()
668            .as_secs();
669        self.expires_at.saturating_sub(now)
670    }
671}
672
673/// In-memory tracker for certificate lifecycle management.
674///
675/// Stores issued certificates and provides methods to identify which
676/// certificates need renewal or have expired.
677pub struct CertificateTracker {
678    records: Arc<RwLock<HashMap<String, CertificateRecord>>>,
679    /// Default renewal window in seconds (how long before expiry to renew).
680    renew_before_secs: u64,
681}
682
683impl CertificateTracker {
684    /// Create a new tracker with a default 30-day renewal window.
685    pub fn new() -> Self {
686        Self {
687            records: Arc::new(RwLock::new(HashMap::new())),
688            renew_before_secs: 30 * 24 * 3600, // 30 days
689        }
690    }
691
692    /// Create a tracker with a custom renewal window.
693    pub fn with_renew_window(renew_before_secs: u64) -> Self {
694        Self {
695            records: Arc::new(RwLock::new(HashMap::new())),
696            renew_before_secs,
697        }
698    }
699
700    /// Store a certificate record. The key is the primary domain name.
701    pub async fn track(
702        &self,
703        domains: Vec<String>,
704        pem_chain: String,
705        issued_at: u64,
706        expires_at: u64,
707    ) -> String {
708        let key = domains.first().cloned().unwrap_or_default();
709        let record = CertificateRecord::new(
710            domains,
711            pem_chain,
712            issued_at,
713            expires_at,
714            self.renew_before_secs,
715        );
716        self.records.write().await.insert(key.clone(), record);
717        key
718    }
719
720    /// Get a certificate record by primary domain.
721    pub async fn get(&self, domain: &str) -> Option<CertificateRecord> {
722        self.records.read().await.get(domain).cloned()
723    }
724
725    /// List all domains that need renewal.
726    pub async fn due_for_renewal(&self) -> Vec<String> {
727        self.records
728            .read()
729            .await
730            .iter()
731            .filter(|(_, r)| r.needs_renewal())
732            .map(|(k, _)| k.clone())
733            .collect()
734    }
735
736    /// List all domains with expired certificates.
737    pub async fn expired(&self) -> Vec<String> {
738        self.records
739            .read()
740            .await
741            .iter()
742            .filter(|(_, r)| r.is_expired())
743            .map(|(k, _)| k.clone())
744            .collect()
745    }
746
747    /// Remove expired certificate records.
748    pub async fn remove_expired(&self) -> usize {
749        let mut records = self.records.write().await;
750        let before = records.len();
751        records.retain(|_, r| !r.is_expired());
752        before - records.len()
753    }
754
755    /// Remove a specific record.
756    pub async fn remove(&self, domain: &str) -> bool {
757        self.records.write().await.remove(domain).is_some()
758    }
759
760    /// Count of tracked certificates.
761    pub async fn count(&self) -> usize {
762        self.records.read().await.len()
763    }
764}
765
766impl Default for CertificateTracker {
767    fn default() -> Self {
768        Self::new()
769    }
770}
771
772// ── Pending challenge tracker ───────────────────────────────────────
773
774/// Tracks pending ACME challenges for HTTP-01 validation.
775///
776/// Stores token → key_authorization mappings so the HTTP server can
777/// respond to `/.well-known/acme-challenge/{token}` requests.
778#[derive(Debug, Clone, Default)]
779pub struct Http01ChallengeStore {
780    challenges: Arc<RwLock<HashMap<String, String>>>,
781}
782
783impl Http01ChallengeStore {
784    pub fn new() -> Self {
785        Self::default()
786    }
787
788    /// Register a challenge token with its key authorization.
789    pub async fn add(&self, token: String, key_authorization: String) {
790        self.challenges
791            .write()
792            .await
793            .insert(token, key_authorization);
794    }
795
796    /// Look up a key authorization by token.
797    pub async fn get(&self, token: &str) -> Option<String> {
798        self.challenges.read().await.get(token).cloned()
799    }
800
801    /// Remove a completed challenge.
802    pub async fn remove(&self, token: &str) {
803        self.challenges.write().await.remove(token);
804    }
805
806    /// Count of pending challenges.
807    pub async fn count(&self) -> usize {
808        self.challenges.read().await.len()
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    // ── Config defaults ─────────────────────────────────────────
817
818    #[test]
819    fn test_config_defaults() {
820        let config = AcmeConfig::default();
821        assert!(config.directory_url.contains("staging"));
822        assert!(!config.agree_to_tos);
823        assert!(config.contact_emails.is_empty());
824        assert_eq!(config.timeout_secs, 30);
825    }
826
827    // ── JWK Thumbprint ──────────────────────────────────────────
828
829    #[test]
830    fn test_jwk_thumbprint_p256_format() {
831        // Create a synthetic 65-byte uncompressed P-256 point
832        let mut key = vec![0x04];
833        key.extend_from_slice(&[0xAA; 32]); // x
834        key.extend_from_slice(&[0xBB; 32]); // y
835
836        let thumbprint = jwk_thumbprint_p256(&key);
837        assert!(!thumbprint.is_empty());
838
839        // Thumbprint should be a base64url-encoded SHA-256 (43 chars)
840        assert_eq!(thumbprint.len(), 43);
841    }
842
843    #[test]
844    fn test_jwk_thumbprint_deterministic() {
845        let mut key = vec![0x04];
846        key.extend_from_slice(&[0x11; 32]);
847        key.extend_from_slice(&[0x22; 32]);
848
849        let t1 = jwk_thumbprint_p256(&key);
850        let t2 = jwk_thumbprint_p256(&key);
851        assert_eq!(t1, t2);
852    }
853
854    #[test]
855    fn test_jwk_thumbprint_different_keys() {
856        let mut k1 = vec![0x04];
857        k1.extend_from_slice(&[0x01; 32]);
858        k1.extend_from_slice(&[0x02; 32]);
859
860        let mut k2 = vec![0x04];
861        k2.extend_from_slice(&[0x03; 32]);
862        k2.extend_from_slice(&[0x04; 32]);
863
864        assert_ne!(jwk_thumbprint_p256(&k1), jwk_thumbprint_p256(&k2));
865    }
866
867    #[test]
868    fn test_jwk_thumbprint_invalid_key() {
869        assert_eq!(jwk_thumbprint_p256(&[0x00; 10]), "");
870        assert_eq!(jwk_thumbprint_p256(&[]), "");
871    }
872
873    // ── JWK Builder ─────────────────────────────────────────────
874
875    #[test]
876    fn test_build_p256_jwk() {
877        let mut key = vec![0x04];
878        key.extend_from_slice(&[0xCC; 32]);
879        key.extend_from_slice(&[0xDD; 32]);
880
881        let jwk = build_p256_jwk(&key);
882        assert_eq!(jwk["kty"], "EC");
883        assert_eq!(jwk["crv"], "P-256");
884        assert!(jwk["x"].as_str().is_some());
885        assert!(jwk["y"].as_str().is_some());
886    }
887
888    #[test]
889    fn test_build_p256_jwk_invalid() {
890        let jwk = build_p256_jwk(&[0x00; 10]);
891        assert!(jwk.as_object().unwrap().is_empty());
892    }
893
894    // ── JWS Creation ────────────────────────────────────────────
895
896    #[test]
897    fn test_create_jws_with_jwk() {
898        let rng = SystemRandom::new();
899        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
900        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
901            .unwrap();
902
903        let jws = create_jws(&kp, "https://example.com/new-acct", "nonce1", "{}", None).unwrap();
904        assert!(jws.get("protected").is_some());
905        assert!(jws.get("payload").is_some());
906        assert!(jws.get("signature").is_some());
907    }
908
909    #[test]
910    fn test_create_jws_with_kid() {
911        let rng = SystemRandom::new();
912        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
913        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
914            .unwrap();
915
916        let jws = create_jws(
917            &kp,
918            "https://example.com/order",
919            "nonce2",
920            "{}",
921            Some("https://example.com/acct/1"),
922        )
923        .unwrap();
924
925        // Decode the protected header to verify kid is present
926        let protected = jws["protected"].as_str().unwrap();
927        let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
928            .decode(protected)
929            .unwrap();
930        let header: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
931        assert_eq!(header["kid"], "https://example.com/acct/1");
932        assert!(header.get("jwk").is_none());
933    }
934
935    #[test]
936    fn test_create_jws_post_as_get() {
937        let rng = SystemRandom::new();
938        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
939        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
940            .unwrap();
941
942        let jws = create_jws(&kp, "https://example.com/authz", "nonce3", "", None).unwrap();
943        // POST-as-GET: empty payload
944        assert_eq!(jws["payload"].as_str().unwrap(), "");
945    }
946
947    // ── Key Authorization ───────────────────────────────────────
948
949    #[test]
950    fn test_key_authorization_format() {
951        let rng = SystemRandom::new();
952        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
953        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
954            .unwrap();
955
956        let dir = AcmeDirectory {
957            new_nonce: String::new(),
958            new_account: String::new(),
959            new_order: String::new(),
960            new_authz: None,
961            revoke_cert: None,
962            key_change: None,
963            meta: None,
964        };
965
966        let client = AcmeClient::from_parts(AcmeConfig::default(), kp, dir);
967        let key_authz = client.key_authorization("test-token");
968        assert!(key_authz.starts_with("test-token."));
969        assert!(key_authz.len() > 20); // token + '.' + thumbprint
970    }
971
972    #[test]
973    fn test_dns01_record_value() {
974        let rng = SystemRandom::new();
975        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
976        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
977            .unwrap();
978
979        let dir = AcmeDirectory {
980            new_nonce: String::new(),
981            new_account: String::new(),
982            new_order: String::new(),
983            new_authz: None,
984            revoke_cert: None,
985            key_change: None,
986            meta: None,
987        };
988
989        let client = AcmeClient::from_parts(AcmeConfig::default(), kp, dir);
990        let value = client.dns01_record_value("dns-token");
991        // Should be base64url-encoded SHA-256 = 43 chars
992        assert_eq!(value.len(), 43);
993    }
994
995    // ── HTTP-01 Challenge Store ─────────────────────────────────
996
997    #[tokio::test]
998    async fn test_http01_challenge_store() {
999        let store = Http01ChallengeStore::new();
1000        assert_eq!(store.count().await, 0);
1001
1002        store.add("token1".to_string(), "authz1".to_string()).await;
1003        store.add("token2".to_string(), "authz2".to_string()).await;
1004        assert_eq!(store.count().await, 2);
1005
1006        assert_eq!(store.get("token1").await, Some("authz1".to_string()));
1007        assert_eq!(store.get("token2").await, Some("authz2".to_string()));
1008        assert_eq!(store.get("missing").await, None);
1009
1010        store.remove("token1").await;
1011        assert_eq!(store.count().await, 1);
1012        assert_eq!(store.get("token1").await, None);
1013    }
1014
1015    // ── Data model serialization ────────────────────────────────
1016
1017    #[test]
1018    fn test_order_status_serialization() {
1019        assert_eq!(
1020            serde_json::to_string(&OrderStatus::Pending).unwrap(),
1021            r#""pending""#
1022        );
1023        assert_eq!(
1024            serde_json::to_string(&OrderStatus::Ready).unwrap(),
1025            r#""ready""#
1026        );
1027    }
1028
1029    #[test]
1030    fn test_challenge_type_serialization() {
1031        assert_eq!(
1032            serde_json::to_string(&ChallengeType::Http01).unwrap(),
1033            r#""http-01""#
1034        );
1035        assert_eq!(
1036            serde_json::to_string(&ChallengeType::Dns01).unwrap(),
1037            r#""dns-01""#
1038        );
1039    }
1040
1041    #[test]
1042    fn test_acme_identifier() {
1043        let id = AcmeIdentifier {
1044            id_type: "dns".to_string(),
1045            value: "example.com".to_string(),
1046        };
1047        let json = serde_json::to_value(&id).unwrap();
1048        assert_eq!(json["type"], "dns");
1049        assert_eq!(json["value"], "example.com");
1050    }
1051
1052    #[test]
1053    fn test_acme_directory_deserialization() {
1054        let json = r#"{
1055            "newNonce": "https://acme.example/nonce",
1056            "newAccount": "https://acme.example/account",
1057            "newOrder": "https://acme.example/order",
1058            "revokeCert": "https://acme.example/revoke",
1059            "meta": {
1060                "termsOfService": "https://acme.example/tos",
1061                "externalAccountRequired": false
1062            }
1063        }"#;
1064        let dir: AcmeDirectory = serde_json::from_str(json).unwrap();
1065        assert_eq!(dir.new_nonce, "https://acme.example/nonce");
1066        assert_eq!(dir.new_account, "https://acme.example/account");
1067        assert!(dir.meta.is_some());
1068        assert!(!dir.meta.unwrap().external_account_required);
1069    }
1070
1071    #[test]
1072    fn test_acme_authorization_deserialization() {
1073        let json = r#"{
1074            "identifier": {"type": "dns", "value": "example.com"},
1075            "status": "pending",
1076            "challenges": [
1077                {
1078                    "type": "http-01",
1079                    "url": "https://acme.example/chall/1",
1080                    "status": "pending",
1081                    "token": "abc123"
1082                }
1083            ]
1084        }"#;
1085        let authz: AcmeAuthorization = serde_json::from_str(json).unwrap();
1086        assert_eq!(authz.status, AuthorizationStatus::Pending);
1087        assert_eq!(authz.challenges.len(), 1);
1088        assert_eq!(authz.challenges[0].challenge_type, ChallengeType::Http01);
1089        assert_eq!(authz.challenges[0].token, "abc123");
1090    }
1091
1092    #[test]
1093    fn test_account_thumbprint() {
1094        let rng = SystemRandom::new();
1095        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
1096        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1097            .unwrap();
1098
1099        let dir = AcmeDirectory {
1100            new_nonce: String::new(),
1101            new_account: String::new(),
1102            new_order: String::new(),
1103            new_authz: None,
1104            revoke_cert: None,
1105            key_change: None,
1106            meta: None,
1107        };
1108
1109        let client = AcmeClient::from_parts(AcmeConfig::default(), kp, dir);
1110        let tp = client.account_thumbprint();
1111        assert_eq!(tp.len(), 43);
1112
1113        // Must be deterministic
1114        assert_eq!(tp, client.account_thumbprint());
1115    }
1116
1117    // ── Certificate Record ──────────────────────────────────────
1118
1119    #[test]
1120    fn test_certificate_record_not_expired() {
1121        let now = std::time::SystemTime::now()
1122            .duration_since(std::time::UNIX_EPOCH)
1123            .unwrap()
1124            .as_secs();
1125        let record = CertificateRecord::new(
1126            vec!["example.com".to_string()],
1127            "PEM".to_string(),
1128            now,
1129            now + 90 * 24 * 3600, // 90 days from now
1130            30 * 24 * 3600,       // renew 30 days before
1131        );
1132        assert!(!record.is_expired());
1133        assert!(!record.needs_renewal());
1134        assert!(record.remaining_secs() > 0);
1135    }
1136
1137    #[test]
1138    fn test_certificate_record_expired() {
1139        let record = CertificateRecord::new(
1140            vec!["old.com".to_string()],
1141            "PEM".to_string(),
1142            1000,
1143            2000, // expired long ago
1144            300,
1145        );
1146        assert!(record.is_expired());
1147        assert!(record.needs_renewal());
1148        assert_eq!(record.remaining_secs(), 0);
1149    }
1150
1151    #[test]
1152    fn test_certificate_record_needs_renewal() {
1153        let now = std::time::SystemTime::now()
1154            .duration_since(std::time::UNIX_EPOCH)
1155            .unwrap()
1156            .as_secs();
1157        // Expires in 10 days, renew window is 30 days → needs renewal
1158        let record = CertificateRecord::new(
1159            vec!["renew.com".to_string()],
1160            "PEM".to_string(),
1161            now - 80 * 24 * 3600,
1162            now + 10 * 24 * 3600,
1163            30 * 24 * 3600,
1164        );
1165        assert!(!record.is_expired());
1166        assert!(record.needs_renewal());
1167    }
1168
1169    // ── Certificate Tracker ─────────────────────────────────────
1170
1171    #[tokio::test]
1172    async fn test_certificate_tracker_track_and_get() {
1173        let tracker = CertificateTracker::new();
1174        let now = std::time::SystemTime::now()
1175            .duration_since(std::time::UNIX_EPOCH)
1176            .unwrap()
1177            .as_secs();
1178        let key = tracker
1179            .track(
1180                vec!["example.com".to_string()],
1181                "-----BEGIN CERT-----".to_string(),
1182                now,
1183                now + 90 * 24 * 3600,
1184            )
1185            .await;
1186        assert_eq!(key, "example.com");
1187        assert_eq!(tracker.count().await, 1);
1188
1189        let record = tracker.get("example.com").await.unwrap();
1190        assert_eq!(record.domains, vec!["example.com"]);
1191    }
1192
1193    #[tokio::test]
1194    async fn test_certificate_tracker_due_for_renewal() {
1195        let tracker = CertificateTracker::with_renew_window(30 * 24 * 3600);
1196        let now = std::time::SystemTime::now()
1197            .duration_since(std::time::UNIX_EPOCH)
1198            .unwrap()
1199            .as_secs();
1200
1201        // Cert expiring in 10 days (within 30-day window)
1202        tracker
1203            .track(
1204                vec!["renew-me.com".to_string()],
1205                "PEM".to_string(),
1206                now - 80 * 24 * 3600,
1207                now + 10 * 24 * 3600,
1208            )
1209            .await;
1210
1211        // Cert expiring in 60 days (outside 30-day window)
1212        tracker
1213            .track(
1214                vec!["still-good.com".to_string()],
1215                "PEM".to_string(),
1216                now,
1217                now + 60 * 24 * 3600,
1218            )
1219            .await;
1220
1221        let due = tracker.due_for_renewal().await;
1222        assert_eq!(due.len(), 1);
1223        assert!(due.contains(&"renew-me.com".to_string()));
1224    }
1225
1226    #[tokio::test]
1227    async fn test_certificate_tracker_expired() {
1228        let tracker = CertificateTracker::new();
1229        tracker
1230            .track(
1231                vec!["expired.com".to_string()],
1232                "PEM".to_string(),
1233                1000,
1234                2000,
1235            )
1236            .await;
1237        let expired = tracker.expired().await;
1238        assert_eq!(expired, vec!["expired.com"]);
1239    }
1240
1241    #[tokio::test]
1242    async fn test_certificate_tracker_remove_expired() {
1243        let tracker = CertificateTracker::new();
1244        let now = std::time::SystemTime::now()
1245            .duration_since(std::time::UNIX_EPOCH)
1246            .unwrap()
1247            .as_secs();
1248
1249        tracker
1250            .track(vec!["old.com".to_string()], "PEM".to_string(), 100, 200)
1251            .await;
1252        tracker
1253            .track(
1254                vec!["fresh.com".to_string()],
1255                "PEM".to_string(),
1256                now,
1257                now + 9999,
1258            )
1259            .await;
1260
1261        assert_eq!(tracker.count().await, 2);
1262        let removed = tracker.remove_expired().await;
1263        assert_eq!(removed, 1);
1264        assert_eq!(tracker.count().await, 1);
1265        assert!(tracker.get("fresh.com").await.is_some());
1266    }
1267
1268    #[tokio::test]
1269    async fn test_certificate_tracker_remove() {
1270        let tracker = CertificateTracker::new();
1271        let now = std::time::SystemTime::now()
1272            .duration_since(std::time::UNIX_EPOCH)
1273            .unwrap()
1274            .as_secs();
1275        tracker
1276            .track(
1277                vec!["rm.com".to_string()],
1278                "PEM".to_string(),
1279                now,
1280                now + 9999,
1281            )
1282            .await;
1283        assert!(tracker.remove("rm.com").await);
1284        assert!(!tracker.remove("rm.com").await);
1285        assert_eq!(tracker.count().await, 0);
1286    }
1287}