Skip to main content

waf_state_redis/
certstore.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`SharedCertStore`] — the THIRD capability on the same [`RedisStore`] pool (after
5//! `StateStore` and `ReputationStore`): a cluster-wide store for ACME-managed TLS certs plus a
6//! single-writer issuance lock. It closes the "certs are per-node, not coordinated" gap so N
7//! WAF nodes issue ONE cert (not N×, no LE rate-limit pressure, one account) and all serve it.
8//!
9//! Modelled on Caddy/CertMagic: **shared storage** for the cert+key, a **distributed lock** so
10//! exactly one node issues at a time. Enterprise-only, zero core: the driver in
11//! `waf-tls-managed` consumes `dyn SharedCertStore` and keeps the `TlsCertSource::resolver()`
12//! seam unchanged.
13//!
14//! ## Security posture
15//! Serving one cert on N nodes inherently means the private KEY travels through Redis. It is
16//! therefore **encrypted at rest** ([`CertCipher`], AES-256-GCM, key = HKDF-SHA256 of the
17//! operator secret `WAF_CERT_STORE_KEY`) BEFORE it enters Redis — a plaintext key in a shared
18//! store would fail any compliance audit. A **fresh random 96-bit nonce per write** (prepended
19//! to the ciphertext) is mandatory: GCM is catastrophic under nonce reuse with a fixed key.
20//!
21//! ## Lock discipline (same atomicity rigor as the token bucket)
22//! - `try_lock` = `SET k v NX PX ttl` (only one holder).
23//! - `unlock` / `extend_lock` = Lua **compare-and-(del|pexpire)**: touch the lock ONLY if we
24//!   still own it, so we never steal another leader's re-acquired lock after our TTL lapsed.
25//! - `store_cert` = Lua **compare-and-set**: write the cert blob ONLY while we hold the lock, so
26//!   a buggy follower or a race can't overwrite a valid cert (defense in depth, ~zero cost).
27//! - Single-Redis lock (not multi-master Redlock) — consistent with the rate-limit assumption.
28
29use std::time::Duration;
30
31use base64::Engine as _;
32use redis::Script;
33use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
34use ring::rand::{SecureRandom, SystemRandom};
35use serde::{Deserialize, Serialize};
36use tracing::warn;
37
38use crate::{pool_to_redis_err, RedisStore};
39
40/// A managed TLS certificate as the driver sees it: the issued chain, OUR private key, and the
41/// cert's expiry. `key_pem` is the PLAINTEXT key — the type deliberately does NOT derive
42/// `Serialize`, so it can never be persisted in the clear by accident (the on-wire `StoredBlob`
43/// carries the encrypted key instead).
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SharedCert {
46    pub chain_pem: String,
47    pub key_pem: String,
48    pub not_after_unix: i64,
49}
50
51/// The on-wire (Redis) representation: chain and expiry in the clear (chain is public anyway),
52/// the private key **encrypted** (base64 of `nonce||ciphertext||tag`).
53#[derive(Serialize, Deserialize)]
54struct StoredBlob {
55    chain_pem: String,
56    key_pem_enc: String,
57    not_after_unix: i64,
58}
59
60/// Cluster-wide TLS cert storage + single-writer issuance lock — the enterprise injection seam
61/// the DNS-01 driver consumes (the cert analogue of `StateStore`). `key` is the caller's logical
62/// identifier (the sorted-domain-set hash); the store owns the Redis keyspace derived from it.
63pub trait SharedCertStore: Send + Sync {
64    /// The currently stored cert for `key`, or `None` if absent / unreadable / undecryptable.
65    fn load_cert(&self, key: &str) -> Option<SharedCert>;
66    /// Store `cert` for `key`, but ONLY if we still hold the issuance lock as `holder` (atomic
67    /// compare-and-set). Returns `true` iff the blob was written.
68    fn store_cert(&self, key: &str, holder: &str, cert: &SharedCert) -> bool;
69    /// Try to become the single writer for `key` (`SET NX PX`). `true` iff acquired.
70    fn try_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool;
71    /// Extend our lock's TTL, ONLY if we still own it (compare-and-pexpire). `false` (lock lost)
72    /// is the signal for the leader to ABORT the in-flight issuance and retry later.
73    fn extend_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool;
74    /// Release the lock, ONLY if we still own it (compare-and-delete).
75    fn unlock(&self, key: &str, holder: &str);
76}
77
78/// `GET k == holder ? PEXPIRE k ttl : 0` — extend only our own lock.
79const EXTEND_LUA: &str = r#"
80if redis.call('GET', KEYS[1]) == ARGV[1] then
81  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
82else
83  return 0
84end
85"#;
86
87/// `GET k == holder ? DEL k : 0` — delete only our own lock.
88const UNLOCK_LUA: &str = r#"
89if redis.call('GET', KEYS[1]) == ARGV[1] then
90  return redis.call('DEL', KEYS[1])
91else
92  return 0
93end
94"#;
95
96/// `GET lock == holder ? SET cert blob, 1 : 0` — write the cert blob only while we hold the lock.
97const STORE_LUA: &str = r#"
98if redis.call('GET', KEYS[1]) == ARGV[1] then
99  redis.call('SET', KEYS[2], ARGV[2])
100  return 1
101else
102  return 0
103end
104"#;
105
106fn cert_key(key: &str) -> String {
107    format!("cert:{key}")
108}
109fn lock_key(key: &str) -> String {
110    format!("issuance_lock:{key}")
111}
112fn feed_lock_key(name: &str) -> String {
113    format!("feed_lock:{name}")
114}
115
116/// A minimal distributed lock (acquire / release) for cluster-wide single-writer coordination,
117/// **separate** from the cert issuance lock (its own `feed_lock:` namespace). The threat-intel feed
118/// uses it so exactly ONE node fetches + writes the reputation snapshot per cycle (avoids N×
119/// external fetches of a possibly-metered subscription). Unlike the cert lock there is **no
120/// heartbeat/extend** (F4): the feed cycle is short, and if the leader dies the next cycle another
121/// node simply acquires — liveness is covered by the entries' TTL, not by holding the lock.
122///
123/// Distinct method names (`acquire`/`release`, not `try_lock`/`unlock`) so a `RedisStore` can carry
124/// both this and [`SharedCertStore`] without an ambiguous call.
125pub trait DistributedLock: Send + Sync {
126    /// Try to become the single holder of `name` (`SET NX PX`). `true` iff acquired.
127    fn acquire(&self, name: &str, holder: &str, ttl: Duration) -> bool;
128    /// Release `name`, ONLY if we still own it (compare-and-delete; reuses the lock's CAS Lua).
129    fn release(&self, name: &str, holder: &str);
130}
131
132impl DistributedLock for RedisStore {
133    fn acquire(&self, name: &str, holder: &str, ttl: Duration) -> bool {
134        let lk = feed_lock_key(name);
135        let ttl_ms = ttl.as_millis().max(1) as u64;
136        let result = blocking(|| -> redis::RedisResult<Option<String>> {
137            let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
138            redis::cmd("SET")
139                .arg(&lk)
140                .arg(holder)
141                .arg("NX")
142                .arg("PX")
143                .arg(ttl_ms)
144                .query::<Option<String>>(&mut *conn)
145        });
146        match result {
147            Ok(Some(_)) => true,
148            Ok(None) => false,
149            Err(e) => {
150                // Fail-safe: cannot confirm ownership ⇒ do NOT claim the lock (avoids two writers).
151                warn!(error = %e, name, "DistributedLock.acquire Redis error — treating as NOT acquired");
152                false
153            }
154        }
155    }
156
157    fn release(&self, name: &str, holder: &str) {
158        let lk = feed_lock_key(name);
159        let script = Script::new(UNLOCK_LUA);
160        let result = blocking(|| -> redis::RedisResult<i64> {
161            let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
162            script.key(&lk).arg(holder).invoke::<i64>(&mut *conn)
163        });
164        if let Err(e) = result {
165            warn!(error = %e, name, "DistributedLock.release Redis error (lock will expire via TTL)");
166        }
167    }
168}
169
170/// Run a blocking Redis closure, yielding the worker for the round-trip when a multi-thread
171/// runtime is present (same sync-on-async discipline as `StateStore`/`ReputationStore`).
172fn blocking<T>(f: impl FnOnce() -> T) -> T {
173    match tokio::runtime::Handle::try_current() {
174        Ok(_) => tokio::task::block_in_place(f),
175        Err(_) => f(),
176    }
177}
178
179impl SharedCertStore for RedisStore {
180    fn load_cert(&self, key: &str) -> Option<SharedCert> {
181        let cipher = self.cert_cipher.as_ref().or_else(|| {
182            warn!("SharedCertStore.load_cert called without WAF_CERT_STORE_KEY configured");
183            None
184        })?;
185        let ck = cert_key(key);
186        let result = blocking(|| -> redis::RedisResult<Option<String>> {
187            let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
188            redis::cmd("GET").arg(&ck).query::<Option<String>>(&mut *conn)
189        });
190        let json = match result {
191            Ok(Some(j)) => j,
192            Ok(None) => return None,
193            Err(e) => {
194                warn!(error = %e, key = %key, "SharedCertStore.load_cert Redis error");
195                return None;
196            }
197        };
198        let blob: StoredBlob = match serde_json::from_str(&json) {
199            Ok(b) => b,
200            Err(e) => {
201                warn!(error = %e, key = %key, "SharedCertStore.load_cert: malformed blob");
202                return None;
203            }
204        };
205        let key_pem = match cipher.decrypt_b64(&blob.key_pem_enc) {
206            Ok(bytes) => match String::from_utf8(bytes) {
207                Ok(s) => s,
208                Err(_) => {
209                    warn!(key = %key, "SharedCertStore.load_cert: decrypted key not UTF-8");
210                    return None;
211                }
212            },
213            Err(_) => {
214                warn!(key = %key, "SharedCertStore.load_cert: key decryption failed (wrong WAF_CERT_STORE_KEY?)");
215                return None;
216            }
217        };
218        Some(SharedCert { chain_pem: blob.chain_pem, key_pem, not_after_unix: blob.not_after_unix })
219    }
220
221    fn store_cert(&self, key: &str, holder: &str, cert: &SharedCert) -> bool {
222        let Some(cipher) = self.cert_cipher.as_ref() else {
223            warn!("SharedCertStore.store_cert called without WAF_CERT_STORE_KEY configured");
224            return false;
225        };
226        let blob = StoredBlob {
227            chain_pem: cert.chain_pem.clone(),
228            key_pem_enc: cipher.encrypt_b64(cert.key_pem.as_bytes()),
229            not_after_unix: cert.not_after_unix,
230        };
231        let json = match serde_json::to_string(&blob) {
232            Ok(j) => j,
233            Err(e) => {
234                warn!(error = %e, key = %key, "SharedCertStore.store_cert: serialize failed");
235                return false;
236            }
237        };
238        let (lk, ck) = (lock_key(key), cert_key(key));
239        let script = Script::new(STORE_LUA);
240        let result = blocking(|| -> redis::RedisResult<i64> {
241            let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
242            script.key(&lk).key(&ck).arg(holder).arg(&json).invoke::<i64>(&mut *conn)
243        });
244        match result {
245            Ok(1) => true,
246            Ok(_) => {
247                warn!(key = %key, holder = %holder, "SharedCertStore.store_cert: lock not held, blob NOT written");
248                false
249            }
250            Err(e) => {
251                warn!(error = %e, key = %key, "SharedCertStore.store_cert Redis error");
252                false
253            }
254        }
255    }
256
257    fn try_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool {
258        let lk = lock_key(key);
259        let ttl_ms = ttl.as_millis().max(1) as u64;
260        let result = blocking(|| -> redis::RedisResult<Option<String>> {
261            let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
262            redis::cmd("SET")
263                .arg(&lk)
264                .arg(holder)
265                .arg("NX")
266                .arg("PX")
267                .arg(ttl_ms)
268                .query::<Option<String>>(&mut *conn)
269        });
270        match result {
271            Ok(Some(_)) => true, // "OK" ⇒ acquired
272            Ok(None) => false,   // held by someone else
273            Err(e) => {
274                // Fail-safe: cannot confirm ownership ⇒ do NOT claim the lock (avoids two leaders).
275                warn!(error = %e, key = %key, "SharedCertStore.try_lock Redis error — treating as NOT acquired");
276                false
277            }
278        }
279    }
280
281    fn extend_lock(&self, key: &str, holder: &str, ttl: Duration) -> bool {
282        let lk = lock_key(key);
283        let ttl_ms = ttl.as_millis().max(1) as u64;
284        let script = Script::new(EXTEND_LUA);
285        let result = blocking(|| -> redis::RedisResult<i64> {
286            let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
287            script.key(&lk).arg(holder).arg(ttl_ms).invoke::<i64>(&mut *conn)
288        });
289        matches!(result, Ok(1))
290    }
291
292    fn unlock(&self, key: &str, holder: &str) {
293        let lk = lock_key(key);
294        let script = Script::new(UNLOCK_LUA);
295        let result = blocking(|| -> redis::RedisResult<i64> {
296            let mut conn = self.pool.get().map_err(pool_to_redis_err)?;
297            script.key(&lk).arg(holder).invoke::<i64>(&mut *conn)
298        });
299        if let Err(e) = result {
300            warn!(error = %e, key = %key, "SharedCertStore.unlock Redis error (lock will expire via TTL)");
301        }
302    }
303}
304
305// ── Encryption at rest for the private key ──────────────────────────────────────
306
307/// HKDF context values. Bumping the version string rotates all derived keys (and invalidates old
308/// blobs), which is the intended "cache is invalid, re-issue" behaviour.
309const HKDF_SALT: &[u8] = b"waf-sharedcertstore-v1";
310const HKDF_INFO: &[u8] = b"aes-256-gcm-key";
311const AES_256_KEY_LEN: usize = 32;
312
313/// Length hint for `ring::hkdf::Prk::expand` (ring expands into a type that reports its length).
314struct HkdfLen(usize);
315impl ring::hkdf::KeyType for HkdfLen {
316    fn len(&self) -> usize {
317        self.0
318    }
319}
320
321/// AES-256-GCM authenticated encryption for the stored private key. The key is derived from the
322/// operator secret via HKDF-SHA256; a fresh random 96-bit nonce is generated per encryption and
323/// prepended to the ciphertext. Storing the derived key (not the passphrase) keeps the passphrase
324/// out of memory after construction.
325pub struct CertCipher {
326    key: LessSafeKey,
327    rng: SystemRandom,
328}
329
330/// Decryption failed (wrong key, or tampered / truncated ciphertext).
331#[derive(Debug)]
332pub struct DecryptError;
333
334impl std::fmt::Display for DecryptError {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        write!(f, "cert key decryption failed")
337    }
338}
339impl std::error::Error for DecryptError {}
340
341impl CertCipher {
342    /// Derive the AES-256-GCM key from an operator secret (the `WAF_CERT_STORE_KEY` env value).
343    pub fn from_secret(secret: &str) -> Self {
344        let salt = ring::hkdf::Salt::new(ring::hkdf::HKDF_SHA256, HKDF_SALT);
345        let prk = salt.extract(secret.as_bytes());
346        let okm = prk
347            .expand(&[HKDF_INFO], HkdfLen(AES_256_KEY_LEN))
348            .expect("HKDF expand to a fixed 32-byte length cannot fail");
349        let mut key_bytes = [0u8; AES_256_KEY_LEN];
350        okm.fill(&mut key_bytes).expect("HKDF fill of 32 bytes cannot fail");
351        let unbound = UnboundKey::new(&AES_256_GCM, &key_bytes)
352            .expect("AES-256-GCM key from 32 bytes cannot fail");
353        Self { key: LessSafeKey::new(unbound), rng: SystemRandom::new() }
354    }
355
356    /// Encrypt `plaintext` → `base64(nonce || ciphertext || tag)`. A fresh random nonce per call
357    /// (never reused) is the load-bearing safety property for GCM.
358    fn encrypt_b64(&self, plaintext: &[u8]) -> String {
359        let mut nonce_bytes = [0u8; NONCE_LEN];
360        self.rng.fill(&mut nonce_bytes).expect("system RNG failed");
361        let nonce = Nonce::assume_unique_for_key(nonce_bytes);
362        let mut in_out = plaintext.to_vec();
363        self.key
364            .seal_in_place_append_tag(nonce, Aad::empty(), &mut in_out)
365            .expect("AES-GCM seal cannot fail");
366        let mut blob = Vec::with_capacity(NONCE_LEN + in_out.len());
367        blob.extend_from_slice(&nonce_bytes);
368        blob.extend_from_slice(&in_out);
369        base64::engine::general_purpose::STANDARD.encode(blob)
370    }
371
372    /// Inverse of [`Self::encrypt_b64`]. Any error (bad base64, wrong key, tamper, truncation)
373    /// collapses to [`DecryptError`] — the caller treats a bad blob as "no cert".
374    fn decrypt_b64(&self, b64: &str) -> Result<Vec<u8>, DecryptError> {
375        let blob = base64::engine::general_purpose::STANDARD
376            .decode(b64)
377            .map_err(|_| DecryptError)?;
378        // At least a nonce + a GCM tag (16 bytes) must be present.
379        if blob.len() < NONCE_LEN + AES_256_GCM.tag_len() {
380            return Err(DecryptError);
381        }
382        let (nonce_bytes, ct) = blob.split_at(NONCE_LEN);
383        let nonce = Nonce::try_assume_unique_for_key(nonce_bytes).map_err(|_| DecryptError)?;
384        let mut in_out = ct.to_vec();
385        let plaintext = self
386            .key
387            .open_in_place(nonce, Aad::empty(), &mut in_out)
388            .map_err(|_| DecryptError)?;
389        Ok(plaintext.to_vec())
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn encrypt_then_decrypt_round_trips() {
399        let c = CertCipher::from_secret("correct horse battery staple");
400        let pt = b"-----BEGIN PRIVATE KEY-----\nMIIabc...\n-----END PRIVATE KEY-----\n";
401        let blob = c.encrypt_b64(pt);
402        assert_eq!(c.decrypt_b64(&blob).unwrap(), pt);
403    }
404
405    #[test]
406    fn each_encryption_uses_a_fresh_nonce() {
407        // Same plaintext + same key MUST yield different ciphertexts (random nonce), else a
408        // nonce is being reused — the catastrophic GCM failure mode.
409        let c = CertCipher::from_secret("s3cret");
410        let pt = b"same input every time";
411        assert_ne!(c.encrypt_b64(pt), c.encrypt_b64(pt), "nonce reuse detected");
412    }
413
414    #[test]
415    fn wrong_secret_fails_to_decrypt() {
416        let a = CertCipher::from_secret("secret-A");
417        let b = CertCipher::from_secret("secret-B");
418        let blob = a.encrypt_b64(b"top secret key material");
419        assert!(b.decrypt_b64(&blob).is_err(), "a different secret must not decrypt");
420    }
421
422    #[test]
423    fn tampered_ciphertext_is_rejected() {
424        let c = CertCipher::from_secret("s3cret");
425        let blob = c.encrypt_b64(b"authentic");
426        let mut raw = base64::engine::general_purpose::STANDARD.decode(&blob).unwrap();
427        let last = raw.len() - 1;
428        raw[last] ^= 0xff; // flip a byte in the tag → auth must fail
429        let tampered = base64::engine::general_purpose::STANDARD.encode(raw);
430        assert!(c.decrypt_b64(&tampered).is_err(), "GCM must reject tampering");
431    }
432
433    #[test]
434    fn truncated_blob_is_rejected() {
435        let c = CertCipher::from_secret("s3cret");
436        assert!(c.decrypt_b64("").is_err());
437        assert!(c.decrypt_b64("AAAA").is_err()); // shorter than nonce+tag
438    }
439
440    #[test]
441    fn cert_and_lock_keys_are_namespaced() {
442        assert_eq!(cert_key("abc"), "cert:abc");
443        assert_eq!(lock_key("abc"), "issuance_lock:abc");
444        // The feed lock lives in its OWN namespace, so it can never collide with a cert lock.
445        assert_eq!(feed_lock_key("reputation-feed"), "feed_lock:reputation-feed");
446    }
447}