Skip to main content

ssh_cli/tls/
acme.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![forbid(unsafe_code)]
3//! ACME (RFC 8555) via `instant-acme` — DNS-01, XDG storage, agent two-step flow.
4//!
5//! ## Agent-friendly flow
6//!
7//! 1. `tls acme issue --domain example.com --print-challenge`  
8//!    Creates order, prints TXT challenge JSON, persists `order_url` under XDG.
9//! 2. Operator sets `_acme-challenge.example.com` TXT to the printed value.
10//! 3. `tls acme complete --domain example.com`  
11//!    Resumes the **same** order via URL, `set_ready`, finalize, writes PEMs (0o600).
12
13use std::path::Path;
14
15use instant_acme::{
16    Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt,
17    NewAccount, NewOrder, OrderStatus, RetryPolicy,
18};
19use serde::{Deserialize, Serialize};
20
21use super::paths::{
22    acme_account_path, acme_domain_dir, cert_pem_path, ensure_dir, key_pem_path, order_json_path,
23    write_secret_file,
24};
25use super::provider::ensure_provider;
26use super::acme_error_map::map_acme_error;
27use crate::domain::{AcmeOrderUrl, HttpsUrl, Rfc3339Utc};
28use crate::errors::{SshCliError, SshCliResult};
29
30/// ACME directory selection.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum AcmeDirectory {
33    /// Let's Encrypt production.
34    #[default]
35    Production,
36    /// Let's Encrypt staging (no production rate-limits; untrusted chain).
37    Staging,
38}
39
40impl AcmeDirectory {
41    /// Validated HTTPS directory URL (from `instant-acme` constants).
42    fn url(self) -> SshCliResult<HttpsUrl> {
43        let raw = match self {
44            Self::Production => LetsEncrypt::Production.url(),
45            Self::Staging => LetsEncrypt::Staging.url(),
46        };
47        HttpsUrl::try_new(raw).map_err(|e| SshCliError::tls_msg(format!("ACME directory URL: {e}")))
48    }
49
50    fn label(self) -> &'static str {
51        match self {
52            Self::Production => "production",
53            Self::Staging => "staging",
54        }
55    }
56}
57
58/// Persisted pending order (challenge phase) — resume via `order_url`.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PendingOrder {
61    /// Domain being ordered.
62    pub domain: String,
63    /// Directory label (`production` / `staging`).
64    pub directory: String,
65    /// ACME order URL (required to resume the same challenge token).
66    pub order_url: AcmeOrderUrl,
67    /// DNS TXT name (`_acme-challenge.<domain>`).
68    pub dns_name: String,
69    /// DNS TXT value for the challenge.
70    pub dns_value: String,
71    /// RFC 3339 UTC when the challenge was printed.
72    pub created_at: Rfc3339Utc,
73}
74
75/// Account status for JSON/text output.
76#[derive(Debug, Clone, Serialize)]
77pub struct AccountStatus {
78    /// Whether account credentials exist on disk.
79    pub present: bool,
80    /// Absolute path to account JSON.
81    pub path: String,
82    /// Directory used when account was created (if known).
83    pub directory: Option<String>,
84}
85
86/// Certificate status for one domain.
87#[derive(Debug, Clone, Serialize)]
88pub struct DomainCertStatus {
89    /// Domain leaf.
90    pub domain: String,
91    /// Cert PEM present.
92    pub has_cert: bool,
93    /// Key PEM present.
94    pub has_key: bool,
95    /// Pending order present.
96    pub has_pending_order: bool,
97    /// Absolute cert path when present.
98    pub cert_path: Option<String>,
99    /// Absolute key path when present.
100    pub key_path: Option<String>,
101}
102
103/// Creates (or refuses if exists unless `force`) an ACME account under XDG.
104pub async fn create_account(
105    config_override: Option<&Path>,
106    directory: AcmeDirectory,
107    contact: &[String],
108    force: bool,
109) -> SshCliResult<AccountStatus> {
110    ensure_provider()?;
111    let path = acme_account_path(config_override)?;
112    if path.exists() && !force {
113        return Err(SshCliError::InvalidArgument(format!(
114            "ACME account already exists at {}; use --force to replace",
115            path.display()
116        )));
117    }
118    // G-AUD-06/28: require mailto contact(s) before talking to the CA.
119    if contact.is_empty() {
120        return Err(SshCliError::InvalidArgument(
121            "ACME account create requires at least one --contact mailto:address".into(),
122        ));
123    }
124    for c in contact {
125        let c = c.trim();
126        if !c.to_ascii_lowercase().starts_with("mailto:")
127            || c.len() <= "mailto:".len()
128            || !c.contains('@')
129        {
130            return Err(SshCliError::InvalidArgument(format!(
131                "invalid ACME contact '{c}'; expected mailto:user@example.com"
132            )));
133        }
134    }
135    if let Some(parent) = path.parent() {
136        ensure_dir(parent)?;
137    }
138
139    let contacts: Vec<&str> = contact.iter().map(String::as_str).collect();
140    let directory_url = directory.url()?;
141    let (_account, credentials) = Account::builder()
142        .map_err(|e| map_acme_error("ACME account builder", e))?
143        .create(
144            &NewAccount {
145                contact: &contacts,
146                terms_of_service_agreed: true,
147                only_return_existing: false,
148            },
149            directory_url.as_str().to_owned(),
150            None,
151        )
152        .await
153        .map_err(|e| map_acme_error("ACME create account", e))?;
154
155    let json = serde_json::to_vec_pretty(&credentials)
156        .map_err(|e| SshCliError::tls_msg(format!("serialize ACME credentials: {e}")))?;
157    write_secret_file(&path, &json)?;
158
159    let meta_path = path.with_extension("meta.json");
160    let meta = serde_json::json!({
161        "directory": directory.label(),
162        "created_at": Rfc3339Utc::now().to_rfc3339(),
163    });
164    write_secret_file(
165        &meta_path,
166        serde_json::to_vec_pretty(&meta)
167            .map_err(|e| SshCliError::tls_msg(format!("meta: {e}")))?
168            .as_slice(),
169    )?;
170
171    Ok(AccountStatus {
172        present: true,
173        path: path.display().to_string(),
174        directory: Some(directory.label().to_owned()),
175    })
176}
177
178/// Loads account presence / path without contacting ACME.
179pub fn load_account_status(config_override: Option<&Path>) -> SshCliResult<AccountStatus> {
180    let path = acme_account_path(config_override)?;
181    let directory = std::fs::read_to_string(path.with_extension("meta.json"))
182        .ok()
183        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
184        .and_then(|v| v.get("directory")?.as_str().map(str::to_owned));
185    Ok(AccountStatus {
186        present: path.is_file(),
187        path: path.display().to_string(),
188        directory,
189    })
190}
191
192async fn load_account(config_override: Option<&Path>) -> SshCliResult<Account> {
193    ensure_provider()?;
194    let path = acme_account_path(config_override)?;
195    if !path.is_file() {
196        return Err(SshCliError::FileNotFound(format!(
197            "ACME account missing at {}; run `ssh-cli tls acme account create`",
198            path.display()
199        )));
200    }
201    let data = std::fs::read_to_string(&path)
202        .map_err(|e| SshCliError::tls_msg(format!("read account: {e}")))?;
203    let credentials: AccountCredentials = serde_json::from_str(&data)
204        .map_err(|e| SshCliError::tls_msg(format!("parse account credentials: {e}")))?;
205    Account::builder()
206        .map_err(|e| map_acme_error("ACME account builder", e))?
207        .from_credentials(credentials)
208        .await
209        .map_err(|e| map_acme_error("restore ACME account", e))
210}
211
212/// Starts DNS-01 order and returns challenge details (does **not** wait for DNS).
213pub async fn acme_issue_print_challenge(
214    config_override: Option<&Path>,
215    domain: &str,
216    directory: AcmeDirectory,
217) -> SshCliResult<PendingOrder> {
218    let account = load_account(config_override).await?;
219    let identifiers = vec![Identifier::Dns(domain.to_owned())];
220    let mut order = account
221        .new_order(&NewOrder::new(identifiers.as_slice()))
222        .await
223        .map_err(|e| map_acme_error("ACME new_order", e))?;
224
225    let order_url = AcmeOrderUrl::try_new(order.url())
226        .map_err(|e| SshCliError::tls_msg(format!("ACME order URL: {e}")))?;
227    let mut dns_name = String::new();
228    let mut dns_value = String::new();
229
230    let mut authorizations = order.authorizations();
231    while let Some(result) = authorizations.next().await {
232        let mut authz = result.map_err(|e| map_acme_error("ACME authz", e))?;
233        match authz.status {
234            AuthorizationStatus::Pending => {}
235            AuthorizationStatus::Valid => continue,
236            other => {
237                return Err(SshCliError::InvalidArgument(format!(
238                    "unexpected ACME authorization status: {other:?}"
239                )));
240            }
241        }
242        let challenge = authz
243            .challenge(ChallengeType::Dns01)
244            .ok_or_else(|| SshCliError::InvalidArgument("no DNS-01 challenge on order".into()))?;
245        dns_name = format!("_acme-challenge.{}", challenge.identifier());
246        dns_value = challenge.key_authorization().dns_value();
247    }
248
249    if dns_value.is_empty() {
250        return Err(SshCliError::InvalidArgument(
251            "ACME order had no pending DNS-01 challenge".into(),
252        ));
253    }
254
255    let pending = PendingOrder {
256        domain: domain.to_owned(),
257        directory: directory.label().to_owned(),
258        order_url,
259        dns_name,
260        dns_value,
261        created_at: Rfc3339Utc::now(),
262    };
263
264    let dir = acme_domain_dir(config_override, domain)?;
265    ensure_dir(&dir)?;
266    let order_path = order_json_path(&dir);
267    let bytes = serde_json::to_vec_pretty(&pending)
268        .map_err(|e| SshCliError::tls_msg(format!("serialize pending order: {e}")))?;
269    write_secret_file(&order_path, &bytes)?;
270
271    Ok(pending)
272}
273
274/// Completes ACME after DNS TXT is published: resume order → set_ready → finalize.
275pub async fn acme_complete(
276    config_override: Option<&Path>,
277    domain: &str,
278) -> SshCliResult<DomainCertStatus> {
279    let dir = acme_domain_dir(config_override, domain)?;
280    let pending_path = order_json_path(&dir);
281    if !pending_path.is_file() {
282        return Err(SshCliError::FileNotFound(format!(
283            "no pending ACME order for {domain}; run `tls acme issue --domain {domain} --print-challenge` first"
284        )));
285    }
286
287    let data = std::fs::read_to_string(&pending_path)
288        .map_err(|e| SshCliError::tls_msg(format!("read pending order: {e}")))?;
289    let pending: PendingOrder = serde_json::from_str(&data)
290        .map_err(|e| SshCliError::tls_msg(format!("parse pending order: {e}")))?;
291
292    let account = load_account(config_override).await?;
293    let mut order = account
294        .order(pending.order_url.to_string_owned())
295        .await
296        .map_err(|e| map_acme_error("ACME resume order", e))?;
297
298    let mut authorizations = order.authorizations();
299    while let Some(result) = authorizations.next().await {
300        let mut authz = result.map_err(|e| map_acme_error("ACME authz", e))?;
301        match authz.status {
302            AuthorizationStatus::Pending => {}
303            AuthorizationStatus::Valid => continue,
304            other => {
305                return Err(SshCliError::InvalidArgument(format!(
306                    "unexpected ACME authorization status: {other:?}"
307                )));
308            }
309        }
310        let mut challenge = authz
311            .challenge(ChallengeType::Dns01)
312            .ok_or_else(|| SshCliError::InvalidArgument("no DNS-01 challenge".into()))?;
313        challenge
314            .set_ready()
315            .await
316            .map_err(|e| map_acme_error("ACME set_ready", e))?;
317    }
318
319    let status = order
320        .poll_ready(&RetryPolicy::default())
321        .await
322        .map_err(|e| map_acme_error("ACME poll_ready", e))?;
323    if status != OrderStatus::Ready {
324        return Err(SshCliError::InvalidArgument(format!(
325            "ACME order not ready (status={status:?}); verify DNS TXT {} = {}",
326            pending.dns_name, pending.dns_value
327        )));
328    }
329
330    let private_key_pem = order
331        .finalize()
332        .await
333        .map_err(|e| map_acme_error("ACME finalize", e))?;
334    let cert_chain_pem = order
335        .poll_certificate(&RetryPolicy::default())
336        .await
337        .map_err(|e| map_acme_error("ACME poll_certificate", e))?;
338
339    ensure_dir(&dir)?;
340    let cert_path = cert_pem_path(&dir);
341    let key_path = key_pem_path(&dir);
342    write_secret_file(&cert_path, cert_chain_pem.as_bytes())?;
343    write_secret_file(&key_path, private_key_pem.as_bytes())?;
344    let _ = std::fs::remove_file(pending_path);
345
346    Ok(DomainCertStatus {
347        domain: domain.to_owned(),
348        has_cert: true,
349        has_key: true,
350        has_pending_order: false,
351        cert_path: Some(cert_path.display().to_string()),
352        key_path: Some(key_path.display().to_string()),
353    })
354}
355
356/// Status for one domain or all under ACME dir.
357pub fn acme_status(
358    config_override: Option<&Path>,
359    domain: Option<&str>,
360) -> SshCliResult<Vec<DomainCertStatus>> {
361    if let Some(d) = domain {
362        return Ok(vec![domain_status(config_override, d)?]);
363    }
364    acme_list(config_override)
365}
366
367/// Lists all domain directories under ACME storage.
368pub fn acme_list(config_override: Option<&Path>) -> SshCliResult<Vec<DomainCertStatus>> {
369    let root = super::paths::resolve_tls_root(config_override)?
370        .join(crate::constants::TLS_ACME_DIR_NAME);
371    if !root.exists() {
372        return Ok(Vec::new());
373    }
374    let mut out = Vec::new();
375    for entry in std::fs::read_dir(&root)
376        .map_err(|e| SshCliError::tls_msg(format!("list acme: {e}")))?
377    {
378        let entry = entry.map_err(|e| SshCliError::tls_msg(format!("list acme entry: {e}")))?;
379        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
380            continue;
381        }
382        if let Some(name) = entry.file_name().to_str() {
383            out.push(domain_status(config_override, name)?);
384        }
385    }
386    out.sort_by(|a, b| a.domain.cmp(&b.domain));
387    Ok(out)
388}
389
390fn domain_status(config_override: Option<&Path>, domain: &str) -> SshCliResult<DomainCertStatus> {
391    let dir = acme_domain_dir(config_override, domain)?;
392    let cert = cert_pem_path(&dir);
393    let key = key_pem_path(&dir);
394    let pending = order_json_path(&dir);
395    Ok(DomainCertStatus {
396        domain: domain.to_owned(),
397        has_cert: cert.is_file(),
398        has_key: key.is_file(),
399        has_pending_order: pending.is_file(),
400        cert_path: cert.is_file().then(|| cert.display().to_string()),
401        key_path: key.is_file().then(|| key.display().to_string()),
402    })
403}