Skip to main content

featherbit/acme/storage/
redis.rs

1//! `stores:`-backed `CertStorage` (redis/valkey; `redis-store` feature).
2//!
3//! Keys under the store's `key_prefix`: `acme:account`, `acme:cert:{<id>}`,
4//! `acme:challenge:<domain>` (with TTL), `acme:lease:{<id>}` (`SET NX PX`,
5//! owner-checked renew/release via Lua). Account credentials and certificate
6//! private keys are sealed with [`CookieSealer`] (AES-256-GCM, key = SHA-256 of
7//! `acme.storage.encryption_key`) before they are written; chains are stored
8//! in clear. Hash tags keep one certificate's keys on a single Cluster slot.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use async_trait::async_trait;
14use redis::AsyncCommands;
15use serde::{Deserialize, Serialize};
16
17use super::CertStorage;
18use crate::acme::{AcmeError, CertId, StoredCert};
19use crate::plugins::resources::PluginResources;
20use crate::plugins::util::cookie_session::CookieSealer;
21use crate::stores::redis_store::RedisStoreClient;
22
23/// Sealed blobs never expire on their own; storage TTLs govern lifetime.
24const SEAL_TTL: Duration = Duration::from_secs(100 * 365 * 86_400);
25
26const RENEW_LEASE_SCRIPT: &str = r#"
27if redis.call('GET', KEYS[1]) == ARGV[1] then
28  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
29else
30  return 0
31end"#;
32
33const RELEASE_LEASE_SCRIPT: &str = r#"
34if redis.call('GET', KEYS[1]) == ARGV[1] then
35  return redis.call('DEL', KEYS[1])
36else
37  return 0
38end"#;
39
40pub(crate) fn account_key(prefix: &str) -> String {
41    format!("{prefix}:{}:account", crate::stores::namespaces::ACME)
42}
43pub(super) fn cert_key(prefix: &str, id: &CertId) -> String {
44    format!(
45        "{prefix}:{}:cert:{{{}}}",
46        crate::stores::namespaces::ACME,
47        id.as_str()
48    )
49}
50fn challenge_key(prefix: &str, domain: &str) -> String {
51    format!(
52        "{prefix}:{}:challenge:{domain}",
53        crate::stores::namespaces::ACME
54    )
55}
56fn lease_key(prefix: &str, id: &CertId) -> String {
57    format!(
58        "{prefix}:{}:lease:{{{}}}",
59        crate::stores::namespaces::ACME,
60        id.as_str()
61    )
62}
63
64#[derive(Serialize, Deserialize)]
65struct CertRecord {
66    chain_pem: String,
67    /// `CookieSealer::seal(key_pem)`.
68    key_sealed: String,
69    issued_at: i64,
70}
71
72/// Holds the *name* of the store, never a client: the client is resolved from
73/// the live [`StoreRegistry`](crate::stores::StoreRegistry) on every call, so a
74/// `PUT /api/stores/:name` credential/URL change (which swaps the registry
75/// inside `PluginResources`) is picked up by the next ACME operation instead of
76/// being pinned to the connection that existed at startup.
77pub struct RedisCertStorage {
78    resources: Arc<PluginResources>,
79    name: String,
80    sealer: CookieSealer,
81    renew_script: redis::Script,
82    release_script: redis::Script,
83}
84
85impl RedisCertStorage {
86    pub fn new(
87        resources: Arc<PluginResources>,
88        name: impl Into<String>,
89        encryption_key: &str,
90    ) -> Self {
91        Self {
92            resources,
93            name: name.into(),
94            sealer: CookieSealer::new(encryption_key),
95            renew_script: redis::Script::new(RENEW_LEASE_SCRIPT),
96            release_script: redis::Script::new(RELEASE_LEASE_SCRIPT),
97        }
98    }
99
100    /// The store's current client, or a storage error naming the store when it
101    /// is no longer declared.
102    pub(crate) fn client(&self) -> Result<Arc<RedisStoreClient>, AcmeError> {
103        self.resources
104            .stores
105            .load()
106            .client(&self.name)
107            .map_err(AcmeError::Storage)
108    }
109
110    /// Resolves the current client *and* a connection from it. Every operation
111    /// goes through here, so both the connection and the key prefix come from
112    /// whatever the registry holds right now.
113    async fn conn(
114        &self,
115    ) -> Result<(Arc<RedisStoreClient>, crate::stores::redis_store::StoreConn), AcmeError> {
116        let client = self.client()?;
117        let conn = client.conn().await.map_err(AcmeError::Storage)?;
118        Ok((client, conn))
119    }
120
121    fn err(&self, what: &str, e: impl std::fmt::Display) -> AcmeError {
122        AcmeError::Storage(format!("store '{}': {what}: {e}", self.name))
123    }
124
125    fn open(&self, sealed: &str, what: &str) -> Result<Vec<u8>, AcmeError> {
126        self.sealer.open(sealed).map_err(|e| {
127            AcmeError::Storage(format!(
128                "store '{}': cannot unseal {what} ({e}) — was acme.storage.encryption_key changed?",
129                self.name
130            ))
131        })
132    }
133}
134
135#[async_trait]
136impl CertStorage for RedisCertStorage {
137    fn label(&self) -> String {
138        format!("store:{}", self.name)
139    }
140
141    async fn load_account(&self) -> Result<Option<Vec<u8>>, AcmeError> {
142        let (client, mut conn) = self.conn().await?;
143        let sealed: Option<String> = conn
144            .get(account_key(client.key_prefix()))
145            .await
146            .map_err(|e| self.err("load_account", e))?;
147        sealed
148            .map(|s| self.open(&s, "account credentials"))
149            .transpose()
150    }
151
152    async fn save_account(&self, creds: &[u8]) -> Result<(), AcmeError> {
153        let (client, mut conn) = self.conn().await?;
154        conn.set::<_, _, ()>(
155            account_key(client.key_prefix()),
156            self.sealer.seal(creds, SEAL_TTL),
157        )
158        .await
159        .map_err(|e| self.err("save_account", e))
160    }
161
162    async fn load_cert(&self, id: &CertId) -> Result<Option<StoredCert>, AcmeError> {
163        let (client, mut conn) = self.conn().await?;
164        let raw: Option<String> = conn
165            .get(cert_key(client.key_prefix(), id))
166            .await
167            .map_err(|e| self.err("load_cert", e))?;
168        let Some(raw) = raw else { return Ok(None) };
169        let rec: CertRecord =
170            serde_json::from_str(&raw).map_err(|e| self.err("load_cert: corrupt record", e))?;
171        let key = self.open(&rec.key_sealed, "certificate private key")?;
172        Ok(Some(StoredCert {
173            chain_pem: rec.chain_pem,
174            key_pem: String::from_utf8(key).map_err(|e| self.err("load_cert: key utf8", e))?,
175            issued_at: rec.issued_at,
176        }))
177    }
178
179    async fn save_cert(&self, id: &CertId, cert: &StoredCert) -> Result<(), AcmeError> {
180        let rec = CertRecord {
181            chain_pem: cert.chain_pem.clone(),
182            key_sealed: self.sealer.seal(cert.key_pem.as_bytes(), SEAL_TTL),
183            issued_at: cert.issued_at,
184        };
185        let (client, mut conn) = self.conn().await?;
186        conn.set::<_, _, ()>(
187            cert_key(client.key_prefix(), id),
188            serde_json::to_string(&rec).unwrap(),
189        )
190        .await
191        .map_err(|e| self.err("save_cert", e))
192    }
193
194    async fn put_challenge(
195        &self,
196        domain: &str,
197        key_auth: &str,
198        ttl: Duration,
199    ) -> Result<(), AcmeError> {
200        let (client, mut conn) = self.conn().await?;
201        redis::cmd("SET")
202            .arg(challenge_key(client.key_prefix(), domain))
203            .arg(key_auth)
204            .arg("PX")
205            .arg(ttl.as_millis().max(1) as u64)
206            .query_async::<()>(&mut conn)
207            .await
208            .map_err(|e| self.err("put_challenge", e))
209    }
210
211    async fn get_challenge(&self, domain: &str) -> Result<Option<String>, AcmeError> {
212        let (client, mut conn) = self.conn().await?;
213        conn.get(challenge_key(client.key_prefix(), domain))
214            .await
215            .map_err(|e| self.err("get_challenge", e))
216    }
217
218    async fn remove_challenge(&self, domain: &str) -> Result<(), AcmeError> {
219        let (client, mut conn) = self.conn().await?;
220        conn.del::<_, ()>(challenge_key(client.key_prefix(), domain))
221            .await
222            .map_err(|e| self.err("remove_challenge", e))
223    }
224
225    async fn try_acquire_lease(
226        &self,
227        id: &CertId,
228        owner: &str,
229        ttl: Duration,
230    ) -> Result<bool, AcmeError> {
231        let (client, mut conn) = self.conn().await?;
232        let key = lease_key(client.key_prefix(), id);
233        let acquired: Option<String> = redis::cmd("SET")
234            .arg(&key)
235            .arg(owner)
236            .arg("NX")
237            .arg("PX")
238            .arg(ttl.as_millis().max(1) as u64)
239            .query_async(&mut conn)
240            .await
241            .map_err(|e| self.err("try_acquire_lease", e))?;
242        if acquired.is_some() {
243            return Ok(true);
244        }
245        // Re-entrant for the current owner (and refreshes its TTL).
246        self.renew_lease(id, owner, ttl).await
247    }
248
249    async fn renew_lease(
250        &self,
251        id: &CertId,
252        owner: &str,
253        ttl: Duration,
254    ) -> Result<bool, AcmeError> {
255        let (client, mut conn) = self.conn().await?;
256        let n: i64 = self
257            .renew_script
258            .key(lease_key(client.key_prefix(), id))
259            .arg(owner)
260            .arg(ttl.as_millis().max(1) as u64)
261            .invoke_async(&mut conn)
262            .await
263            .map_err(|e| self.err("renew_lease", e))?;
264        Ok(n == 1)
265    }
266
267    async fn release_lease(&self, id: &CertId, owner: &str) -> Result<(), AcmeError> {
268        let (client, mut conn) = self.conn().await?;
269        let _: i64 = self
270            .release_script
271            .key(lease_key(client.key_prefix(), id))
272            .arg(owner)
273            .invoke_async(&mut conn)
274            .await
275            .map_err(|e| self.err("release_lease", e))?;
276        Ok(())
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::stores::StoreRegistry;
284    use redis::AsyncCommands;
285
286    fn store_cfg(tag: &str, url: &str) -> crate::config::StoreConfig {
287        serde_yaml::from_str(&format!(
288            "name: acme-live
289type: redis
290url: {url}
291key_prefix: fbacme{tag}{}
292",
293            std::process::id()
294        ))
295        .unwrap()
296    }
297
298    /// `PluginResources` whose registry holds one live `acme-live` store, plus
299    /// the client for direct assertions. `None` when the gate env var is unset.
300    fn live_resources(tag: &str) -> Option<(Arc<PluginResources>, Arc<RedisStoreClient>)> {
301        let Ok(url) = std::env::var("FEATHERBIT_TEST_REDIS_URL") else {
302            eprintln!("skipping: FEATHERBIT_TEST_REDIS_URL not set");
303            return None;
304        };
305        let cfg = store_cfg(tag, &url);
306        let resources = PluginResources::new(None);
307        let registry =
308            StoreRegistry::rebuild(&StoreRegistry::default(), std::slice::from_ref(&cfg), None)
309                .unwrap();
310        let client = registry.client("acme-live").unwrap();
311        resources.stores.store(Arc::new(registry));
312        Some((resources, client))
313    }
314
315    #[tokio::test]
316    async fn redis_storage_satisfies_contract() {
317        let Some((resources, _)) = live_resources("c") else {
318            return;
319        };
320        let storage = Arc::new(RedisCertStorage::new(resources, "acme-live", "test-secret"));
321        assert_eq!(storage.label(), "store:acme-live");
322        crate::acme::storage::contract::run_all(storage).await;
323    }
324
325    #[tokio::test]
326    async fn redis_storage_seals_private_material() {
327        let Some((resources, client)) = live_resources("s") else {
328            return;
329        };
330        let storage = RedisCertStorage::new(resources.clone(), "acme-live", "test-secret");
331        let (id, _) = CertId::from_domains(&["sealed.example.com".into()]).unwrap();
332        let cert = StoredCert {
333            chain_pem: "-----BEGIN CERTIFICATE-----
334AAA
335"
336            .into(),
337            key_pem: "-----BEGIN PRIVATE KEY-----
338SECRET-KEY-BYTES
339"
340            .into(),
341            issued_at: 42,
342        };
343        storage.save_cert(&id, &cert).await.unwrap();
344        storage.save_account(b"ACCOUNT-SECRET").await.unwrap();
345
346        let mut conn = client.conn().await.unwrap();
347        let raw_cert: String = conn.get(cert_key(client.key_prefix(), &id)).await.unwrap();
348        assert!(
349            raw_cert.contains("BEGIN CERTIFICATE"),
350            "chain is stored in clear"
351        );
352        assert!(
353            !raw_cert.contains("SECRET-KEY-BYTES"),
354            "key must be sealed: {raw_cert}"
355        );
356        let raw_acct: String = conn.get(account_key(client.key_prefix())).await.unwrap();
357        assert!(!raw_acct.contains("ACCOUNT-SECRET"));
358
359        // A different secret cannot open it.
360        let other = RedisCertStorage::new(resources, "acme-live", "wrong");
361        assert!(other.load_cert(&id).await.is_err());
362        assert_eq!(storage.load_cert(&id).await.unwrap().unwrap(), cert);
363    }
364
365    /// The store's client is resolved per call, not captured once: swapping the
366    /// registry (what `PUT /api/stores/:name` does) must be visible to the very
367    /// next operation. Observed through `key_prefix`, which is part of the
368    /// client — a storage pinned to the old client would keep writing under the
369    /// old prefix.
370    #[tokio::test]
371    async fn client_is_resolved_per_call_so_a_store_edit_is_picked_up() {
372        let Some((resources, first)) = live_resources("swap1") else {
373            return;
374        };
375        let storage = RedisCertStorage::new(resources.clone(), "acme-live", "test-secret");
376        let (id, _) = CertId::from_domains(&["swap.example.com".into()]).unwrap();
377        let cert = StoredCert {
378            chain_pem: "OLD".into(),
379            key_pem: "OLD-KEY".into(),
380            issued_at: 1,
381        };
382        storage.save_cert(&id, &cert).await.unwrap();
383        assert_eq!(storage.load_cert(&id).await.unwrap().unwrap(), cert);
384
385        // Rebuild the registry for the same store name with a different
386        // key_prefix and swap it in, exactly as an Admin API store edit does.
387        let url = std::env::var("FEATHERBIT_TEST_REDIS_URL").unwrap();
388        let cfg2 = store_cfg("swap2", &url);
389        let registry2 =
390            StoreRegistry::rebuild(&StoreRegistry::default(), std::slice::from_ref(&cfg2), None)
391                .unwrap();
392        let second = registry2.client("acme-live").unwrap();
393        assert_ne!(first.key_prefix(), second.key_prefix());
394        resources.stores.store(Arc::new(registry2));
395
396        // The next read goes through the new client's namespace: the record
397        // written under the old prefix is invisible.
398        assert!(
399            storage.load_cert(&id).await.unwrap().is_none(),
400            "the next call must use the swapped-in client, not the original"
401        );
402        let fresh = StoredCert {
403            chain_pem: "NEW".into(),
404            key_pem: "NEW-KEY".into(),
405            issued_at: 2,
406        };
407        storage.save_cert(&id, &fresh).await.unwrap();
408        let mut conn = second.conn().await.unwrap();
409        let raw: Option<String> = conn.get(cert_key(second.key_prefix(), &id)).await.unwrap();
410        assert!(
411            raw.is_some_and(|r| r.contains("NEW")),
412            "the write landed under the new client's key_prefix"
413        );
414
415        // Dropping the store from the registry surfaces as a storage error,
416        // not a panic or a stale success.
417        resources.stores.store(Arc::new(StoreRegistry::default()));
418        let err = storage.load_cert(&id).await.unwrap_err();
419        assert!(
420            matches!(err, AcmeError::Storage(ref m) if m.contains("acme-live")),
421            "{err}"
422        );
423    }
424}