Skip to main content

boatramp_core/
cert.rs

1//! Cluster-managed TLS certificates.
2//!
3//! In a cluster every node terminates TLS, so issuance must be **coordinated**:
4//! independent per-node issuance races on the ACME DNS-01 `_acme-challenge` TXT
5//! record (a shared `*.deploy.<host>` wildcard has every node writing it) and
6//! wastes CA orders. The fix is the leader-only-job pattern: **issue once on the
7//! leader, distribute to all**.
8//!
9//! Certs are small control-plane metadata, so they live in the **replicated
10//! [`KvStore`]** keyed `cert/<domain>` — over `RaftKv` that means every node
11//! gets every cert in its local applied state (read locally on the SNI hot
12//! path; a joining node gets them via replication). Single-node is the
13//! degenerate case: the same store over the local KV.
14//!
15//! [`ensure_cert`] is the single-flight decision (pure of the CA + rustls): the
16//! leader issues + stores when a cert is missing or near expiry; a follower
17//! never calls the CA — it just serves whatever the leader has replicated. The
18//! live ACME round-trip and the rustls hot-swap are wired by the serve path;
19//! this module is the coordination logic and is fully unit-tested.
20
21use std::sync::Arc;
22
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25
26use crate::envelope::KeyEnvelope;
27use crate::kv::{KvError, KvStore};
28
29// The key-free `CertStatus` view is a pure serde wire type in `boatramp-types`
30// (so the server, CLI, and web console share one definition); re-exported so
31// `boatramp_core::cert::CertStatus` is unchanged.
32pub use boatramp_types::cert::CertStatus;
33
34/// A stored certificate: the PEM chain + private key, plus the expiry used to
35/// decide renewal. Pinned `v1` like every boatramp schema.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct StoredCert {
38    /// Pinned schema discriminant (`v1`).
39    #[serde(default = "crate::schema_version")]
40    pub version: u32,
41    /// The full certificate chain, PEM-encoded.
42    pub chain_pem: String,
43    /// The private key, PEM-encoded.
44    pub key_pem: String,
45    /// `notAfter` as a Unix timestamp (seconds), for renewal decisions.
46    pub not_after_unix: u64,
47}
48
49impl StoredCert {
50    /// Construct a stored cert at the pinned schema version.
51    pub fn new(
52        chain_pem: impl Into<String>,
53        key_pem: impl Into<String>,
54        not_after_unix: u64,
55    ) -> Self {
56        Self {
57            version: crate::SCHEMA_VERSION,
58            chain_pem: chain_pem.into(),
59            key_pem: key_pem.into(),
60            not_after_unix,
61        }
62    }
63}
64
65/// Why a cert-coordination operation failed.
66#[derive(Debug)]
67pub enum CertError {
68    /// An underlying [`KvStore`] error.
69    Kv(KvError),
70    /// (De)serialization of a stored cert failed.
71    Decode(String),
72    /// The issuer (CA round-trip) failed.
73    Issue(String),
74    /// Wrapping/unwrapping the private key at rest failed.
75    Envelope(String),
76}
77
78impl std::fmt::Display for CertError {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            Self::Kv(e) => write!(f, "cert store kv error: {e}"),
82            Self::Decode(m) => write!(f, "cert decode error: {m}"),
83            Self::Issue(m) => write!(f, "cert issuance error: {m}"),
84            Self::Envelope(m) => write!(f, "cert key envelope error: {m}"),
85        }
86    }
87}
88
89impl std::error::Error for CertError {}
90
91impl From<KvError> for CertError {
92    fn from(e: KvError) -> Self {
93        Self::Kv(e)
94    }
95}
96
97/// The KV key a domain's cert is stored under.
98pub fn cert_key(domain: &str) -> String {
99    format!("cert/{domain}")
100}
101
102/// A store of certs by domain, shared across the cluster.
103#[async_trait]
104pub trait CertStore: Send + Sync {
105    /// Load the stored cert for `domain`, or `None`.
106    async fn get(&self, domain: &str) -> Result<Option<StoredCert>, CertError>;
107    /// Store (replacing) the cert for `domain`.
108    async fn put(&self, domain: &str, cert: &StoredCert) -> Result<(), CertError>;
109}
110
111/// A [`CertStore`] over any [`KvStore`] — back it with `RaftKv` for a cluster
112/// (certs replicate to every node) or a local KV for single-node.
113///
114/// With an optional [`KeyEnvelope`], the private key is **wrapped at rest**:
115/// the stored record's `key_pem` holds `hex(wrap(key))` instead of
116/// cleartext, and reads unwrap it — so a cert private key is never cleartext in
117/// the replicated control plane. The `chain_pem` + expiry stay clear (not secret,
118/// and the expiry drives renewal without unwrapping).
119pub struct KvCertStore {
120    kv: Arc<dyn KvStore>,
121    envelope: Option<Arc<dyn KeyEnvelope>>,
122}
123
124impl KvCertStore {
125    /// Build over the given KV backend (private keys stored **cleartext**).
126    pub fn new(kv: Arc<dyn KvStore>) -> Self {
127        Self { kv, envelope: None }
128    }
129
130    /// Build with a [`KeyEnvelope`], so stored private keys are wrapped at rest.
131    pub fn with_envelope(kv: Arc<dyn KvStore>, envelope: Arc<dyn KeyEnvelope>) -> Self {
132        Self {
133            kv,
134            envelope: Some(envelope),
135        }
136    }
137}
138
139#[async_trait]
140impl CertStore for KvCertStore {
141    async fn get(&self, domain: &str) -> Result<Option<StoredCert>, CertError> {
142        let Some(raw) = self.kv.get(&cert_key(domain)).await? else {
143            return Ok(None);
144        };
145        let mut cert: StoredCert =
146            serde_json::from_slice(&raw).map_err(|e| CertError::Decode(e.to_string()))?;
147        if let Some(envelope) = &self.envelope {
148            // `key_pem` holds `hex(wrap(key))`; recover the cleartext PEM.
149            let wrapped =
150                hex::decode(cert.key_pem.trim()).map_err(|e| CertError::Envelope(e.to_string()))?;
151            let plaintext = envelope
152                .unwrap(&wrapped)
153                .await
154                .map_err(|e| CertError::Envelope(e.to_string()))?;
155            cert.key_pem =
156                String::from_utf8(plaintext).map_err(|e| CertError::Envelope(e.to_string()))?;
157        }
158        Ok(Some(cert))
159    }
160
161    async fn put(&self, domain: &str, cert: &StoredCert) -> Result<(), CertError> {
162        // Wrap the private key at rest when an envelope is configured.
163        let to_store = if let Some(envelope) = &self.envelope {
164            let wrapped = envelope
165                .wrap(cert.key_pem.as_bytes())
166                .await
167                .map_err(|e| CertError::Envelope(e.to_string()))?;
168            StoredCert {
169                key_pem: hex::encode(wrapped),
170                ..cert.clone()
171            }
172        } else {
173            cert.clone()
174        };
175        let json = serde_json::to_vec(&to_store).map_err(|e| CertError::Decode(e.to_string()))?;
176        self.kv.put(&cert_key(domain), json).await?;
177        Ok(())
178    }
179}
180
181/// Ensure a usable cert for `domain` is in the store, **issuing once on the
182/// leader**.
183///
184/// - If the stored cert is valid (expires more than `renew_before_secs` after
185///   `now_unix`), return it — no CA call.
186/// - Otherwise, only the **leader** (`is_leader`) calls `issue` and stores the
187///   result (sole writer of the DNS-01 TXT → no races, no duplicate orders). A
188///   follower returns whatever is currently stored (possibly `None` until the
189///   leader has issued + replicated it) and never contacts the CA.
190pub async fn ensure_cert<F, Fut, E>(
191    store: &dyn CertStore,
192    domain: &str,
193    is_leader: bool,
194    now_unix: u64,
195    renew_before_secs: u64,
196    issue: F,
197) -> Result<Option<StoredCert>, CertError>
198where
199    F: FnOnce() -> Fut,
200    Fut: std::future::Future<Output = Result<StoredCert, E>>,
201    E: std::fmt::Display,
202{
203    let existing = store.get(domain).await?;
204    let fresh = existing
205        .as_ref()
206        .is_some_and(|c| c.not_after_unix > now_unix.saturating_add(renew_before_secs));
207    if fresh {
208        return Ok(existing);
209    }
210    if !is_leader {
211        // Followers never issue — they serve the leader's replicated cert.
212        return Ok(existing);
213    }
214    let cert = issue().await.map_err(|e| CertError::Issue(e.to_string()))?;
215    store.put(domain, &cert).await?;
216    Ok(Some(cert))
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::envelope::EnvelopeError;
223    use crate::kv::MemoryKv;
224    use std::sync::atomic::{AtomicUsize, Ordering};
225
226    fn store() -> KvCertStore {
227        KvCertStore::new(Arc::new(MemoryKv::new()))
228    }
229
230    /// A reversible test envelope (byte-reverse + a format tag) — enough to prove
231    /// the store wraps/unwraps around a `KeyEnvelope` without a crypto backend.
232    struct ReverseEnvelope;
233
234    #[async_trait]
235    impl KeyEnvelope for ReverseEnvelope {
236        async fn wrap(&self, plaintext: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
237            let mut out = vec![0xEE];
238            out.extend(plaintext.iter().rev());
239            Ok(out)
240        }
241        async fn unwrap(&self, wrapped: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
242            match wrapped.split_first() {
243                Some((0xEE, rest)) => Ok(rest.iter().rev().copied().collect()),
244                _ => Err(EnvelopeError::new("not a ReverseEnvelope blob")),
245            }
246        }
247    }
248
249    /// With an envelope, the private key is stored wrapped (never cleartext in
250    /// the KV) and reads recover it.
251    #[tokio::test]
252    async fn envelope_wraps_the_key_at_rest_and_reads_recover_it() {
253        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
254        let s = KvCertStore::with_envelope(kv.clone(), Arc::new(ReverseEnvelope));
255        let cert = StoredCert::new("CHAIN", "SECRET-KEY-PEM", 9999);
256        s.put("blog", &cert).await.unwrap();
257
258        // The raw KV bytes never contain the cleartext key.
259        let raw = kv.get(&cert_key("blog")).await.unwrap().unwrap();
260        let raw_str = String::from_utf8_lossy(&raw);
261        assert!(
262            !raw_str.contains("SECRET-KEY-PEM"),
263            "the private key must not be stored in cleartext"
264        );
265        assert!(raw_str.contains("CHAIN"), "the chain stays clear");
266
267        // A read unwraps back to the original cert.
268        let got = s.get("blog").await.unwrap().unwrap();
269        assert_eq!(got, cert);
270    }
271
272    /// A store with the wrong/no envelope can't read a wrapped record (fail-closed).
273    #[tokio::test]
274    async fn wrapped_key_is_unreadable_without_the_envelope() {
275        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
276        KvCertStore::with_envelope(kv.clone(), Arc::new(ReverseEnvelope))
277            .put("blog", &StoredCert::new("C", "K", 1))
278            .await
279            .unwrap();
280        // A plaintext store reads the wrapped hex verbatim (not the real key), and
281        // a mismatched-format unwrap fails — either way the secret isn't exposed.
282        let plain = KvCertStore::new(kv);
283        let got = plain.get("blog").await.unwrap().unwrap();
284        assert_ne!(
285            got.key_pem, "K",
286            "cleartext read must not yield the real key"
287        );
288    }
289
290    /// A fake issuer that counts calls and stamps a far-future expiry.
291    fn issuer(
292        calls: &AtomicUsize,
293        not_after: u64,
294    ) -> impl FnOnce() -> std::future::Ready<Result<StoredCert, String>> + '_ {
295        move || {
296            calls.fetch_add(1, Ordering::SeqCst);
297            std::future::ready(Ok(StoredCert::new("CHAIN", "KEY", not_after)))
298        }
299    }
300
301    #[tokio::test]
302    async fn kv_cert_store_round_trips() {
303        let s = store();
304        assert!(s.get("blog.example.com").await.unwrap().is_none());
305        let cert = StoredCert::new("chain", "key", 1000);
306        s.put("blog.example.com", &cert).await.unwrap();
307        assert_eq!(s.get("blog.example.com").await.unwrap(), Some(cert));
308    }
309
310    #[tokio::test]
311    async fn leader_issues_once_then_serves_from_store() {
312        let s = store();
313        let calls = AtomicUsize::new(0);
314        // No cert yet → leader issues.
315        let c = ensure_cert(&s, "d", true, 100, 50, issuer(&calls, 10_000))
316            .await
317            .unwrap();
318        assert!(c.is_some());
319        assert_eq!(calls.load(Ordering::SeqCst), 1);
320        // Cert is fresh → no re-issue on the next pass.
321        let c2 = ensure_cert(&s, "d", true, 200, 50, issuer(&calls, 10_000))
322            .await
323            .unwrap();
324        assert_eq!(c2.unwrap().chain_pem, "CHAIN");
325        assert_eq!(
326            calls.load(Ordering::SeqCst),
327            1,
328            "fresh cert must not re-issue"
329        );
330    }
331
332    #[tokio::test]
333    async fn follower_never_issues_but_serves_replicated() {
334        let s = store();
335        let calls = AtomicUsize::new(0);
336        // Follower, no cert yet → does not issue, gets None.
337        let c = ensure_cert(&s, "d", false, 100, 50, issuer(&calls, 10_000))
338            .await
339            .unwrap();
340        assert!(c.is_none());
341        assert_eq!(
342            calls.load(Ordering::SeqCst),
343            0,
344            "a follower must not call the CA"
345        );
346        // The leader issues + replicates (simulated by a direct put).
347        s.put("d", &StoredCert::new("CHAIN", "KEY", 10_000))
348            .await
349            .unwrap();
350        // The follower now serves the replicated cert, still without issuing.
351        let c = ensure_cert(&s, "d", false, 200, 50, issuer(&calls, 10_000))
352            .await
353            .unwrap();
354        assert_eq!(c.unwrap().chain_pem, "CHAIN");
355        assert_eq!(calls.load(Ordering::SeqCst), 0);
356    }
357
358    #[tokio::test]
359    async fn leader_renews_near_expiry() {
360        let s = store();
361        let calls = AtomicUsize::new(0);
362        // A cert expiring at 1000; now=900, renew_before=200 → 1000 <= 1100 → renew.
363        s.put("d", &StoredCert::new("OLD", "KEY", 1000))
364            .await
365            .unwrap();
366        let c = ensure_cert(&s, "d", true, 900, 200, issuer(&calls, 99_999))
367            .await
368            .unwrap();
369        assert_eq!(
370            calls.load(Ordering::SeqCst),
371            1,
372            "near-expiry cert must renew"
373        );
374        assert_eq!(c.unwrap().not_after_unix, 99_999);
375    }
376}